257 lines
8.6 KiB
TypeScript
257 lines
8.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|