ai-chatbot/components/canvas/director/DirectorObjectTimeline.tsx

257 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { toast } from "sonner";
import type { DirectorScene } from "@/lib/video-canvas/director-scene";
import { recordObjectFrames } from "@/lib/video-canvas/director-object-tracks";
export function DirectorObjectTimeline({
scene,
time,
selected,
disabled,
preview,
auto,
onAuto,
onChange,
onSeek,
onEdit,
onSelect,
}: {
scene: DirectorScene;
time: number;
selected: string | null;
disabled: boolean;
preview: boolean;
auto: boolean;
onAuto: (v: boolean) => void;
onChange: (s: DirectorScene) => void;
onSeek: (t: number) => void;
onEdit: () => void;
onSelect: (id: string) => void;
}) {
const [unit, setUnit] = useState("seconds"),
[zoom, setZoom] = useState(1);
const factor = unit === "frames" ? 30 : 1,
track = selected ? scene.objectTracks?.[selected] : undefined;
const button = "rounded border border-white/20 px-2 py-1 text-xs disabled:opacity-40",
field = "rounded bg-[#303030] p-1 text-xs";
return (
<fieldset
disabled={disabled}
className="space-y-3 border-t border-white/15 p-3"
aria-label="对象多轨时间轴"
>
<legend className="px-2 text-sm">对象多轨 · 与相机同步</legend>
<div className="flex flex-wrap items-center gap-3">
<label>
单位{" "}
<select
aria-label="时间轴单位"
value={unit}
className={field}
onChange={(e) => setUnit(e.target.value)}
>
<option value="seconds">秒</option>
<option value="frames">帧(30 fps)</option>
</select>
</label>
<label>
缩放{" "}
<select
aria-label="对象时间轴缩放"
value={zoom}
className={field}
onChange={(e) => setZoom(Number(e.target.value))}
>
{[1, 2, 4, 8].map((n) => (
<option key={n} value={n}>
{n}×
</option>
))}
</select>
</label>
<span>
{(time * factor).toFixed(unit === "frames" ? 0 : 2)} /{" "}
{(scene.motion.duration * factor).toFixed(unit === "frames" ? 0 : 2)}{" "}
{unit === "frames" ? "帧" : "秒"}
</span>
<label>
<input type="checkbox" checked={auto} onChange={(e) => onAuto(e.target.checked)} />{" "}
自动记录对象帧
</label>
<button type="button" className={button} onClick={onEdit}>
编辑当前时间对象
</button>
<button
type="button"
className={button}
disabled={preview || !selected}
onClick={() => {
try {
onChange(recordObjectFrames(scene, [selected!], time, () => crypto.randomUUID()));
} catch (e) {
toast.error((e as Error).message);
}
}}
>
记录所选对象帧
</button>
</div>
<div className="overflow-x-auto">
<div style={{ minWidth: `${zoom * 100}%` }}>
<input
aria-label="对象播放头"
type="range"
min={0}
max={scene.motion.duration}
step={1 / 30}
value={time}
className="w-full"
onChange={(e) => onSeek(Number(e.target.value))}
/>
{scene.objects.map((o) => {
const t = scene.objectTracks?.[o.id];
return (
<div
key={o.id}
className="grid grid-cols-[150px_60px_1fr] items-center gap-2 border-t border-white/10 py-2 text-xs"
>
<button
type="button"
className={`truncate text-left ${selected === o.id ? "text-amber-300" : ""}`}
onClick={() => onSelect(o.id)}
>
{o.name}
</button>
<label>
<input
type="checkbox"
aria-label={`${o.name}启用轨道`}
checked={!t?.muted}
onChange={(e) =>
onChange({
...scene,
objectTracks: {
...scene.objectTracks,
[o.id]: {
keyframes: t?.keyframes || [],
easing: t?.easing || "linear",
muted: !e.target.checked,
},
},
})
}
/>
启用
</label>
<div className="relative h-6 rounded bg-white/5">
{t?.keyframes.map((k, i) => (
<button
key={k.id}
type="button"
aria-label={`${o.name}关键帧${i + 1}`}
className="absolute top-0 -translate-x-1/2 text-lg text-amber-300"
style={{ left: `${(k.time / scene.motion.duration) * 100}%` }}
onClick={() => {
onSelect(o.id);
onSeek(k.time);
}}
>
◆
</button>
))}
</div>
</div>
);
})}
</div>
</div>
{track && selected ? (
<div className="space-y-2">
<label>
对象插值{" "}
<select
aria-label="对象插值"
value={track.easing}
className={field}
onChange={(e) =>
onChange({
...scene,
objectTracks: {
...scene.objectTracks,
[selected]: { ...track, easing: e.target.value as "smooth" | "linear" },
},
})
}
>
<option value="linear">匀速</option>
<option value="smooth">缓入缓出</option>
</select>
</label>
<div className="flex flex-wrap gap-2">
{track.keyframes.map((k, i) => (
<label className="flex items-center gap-1" key={k.id}>
帧 {i + 1}
<input
className={`${field} w-20`}
aria-label={`对象关键帧${i + 1}时间`}
type="number"
min={0}
max={scene.motion.duration * factor}
step={unit === "frames" ? 1 : 0.01}
value={Number((k.time * factor).toFixed(2))}
onChange={(e) => {
const next = Number(e.target.value) / factor;
if (
!Number.isFinite(next) ||
next < 0 ||
next > scene.motion.duration ||
track.keyframes.some(
(other) => other.id !== k.id && Math.abs(other.time - next) < 0.001,
)
)
return;
onChange({
...scene,
objectTracks: {
...scene.objectTracks,
[selected]: {
...track,
keyframes: track.keyframes
.map((old) => (old.id === k.id ? { ...old, time: next } : old))
.sort((a, b) => a.time - b.time),
},
},
});
}}
/>
<button
type="button"
className={button}
aria-label={`删除对象关键帧${i + 1}`}
onClick={() =>
onChange({
...scene,
objectTracks: {
...scene.objectTracks,
[selected]: {
...track,
keyframes: track.keyframes.filter((old) => old.id !== k.id),
},
},
})
}
>
删除
</button>
</label>
))}
</div>
</div>
) : null}
<p className="text-xs text-white/50">
拖动播放头预览全部对象;点击“编辑当前时间对象”后调整场景对象,再记录关键帧。真实视频导出使用相同轨道。
</p>
</fieldset>
);
}