{
seek(position(e.clientX));
drag(e, (event) => seek(snapped(position(event.clientX))));
}}
>
{Array.from(
{ length: Math.ceil(Math.max(duration + 1, (viewportWidth - 112) / pps) * 10) },
(_, i) => (
{i % 10 === 0 && {stamp(i / 10)}}
),
)}
{captions.map((s) => (
{
setCaptionId(s.id);
setPanel("caption");
setSubTab("text");
}}
onContextMenu={(e) => {
setCaptionId(s.id);
setPanel("caption");
openContext(e, "caption");
}}
onPointerDown={(e) => {
const x = e.clientX,
original = plan;
let moved = false;
drag(
e,
(event) => {
moved = true;
const start = Math.round(
clamp(
snapped(s.start / SECOND + (event.clientX - x) / pps) * SECOND,
0,
timeline.duration - (s.end - s.start),
),
);
onChange({
...original,
captions: captions.map((c) =>
c.id === s.id ? { ...c, start, end: start + s.end - s.start } : c,
),
});
},
() => {
if (moved) {
undo.current.push(original);
redo.current = [];
}
},
);
}}
>
{s.text}
{captionId === s.id && (
<>
dragCaptionEdge(e, s, "start")}
/>
dragCaptionEdge(e, s, "end")}
/>
>
)}
))}
)}
{timeline.clips.map((c, i) => {
const asset = plan.assets.find((a) => a.id === c.assetId)!,
width = (c.duration / SECOND) * pps;
return (
dragClip(e, i)}
onClick={() => {
setSelected(c.id);
setPanel("video");
setCaptionId(undefined);
}}
onContextMenu={(e) => {
setSelected(c.id);
setPanel("video");
openContext(e, "clip");
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
setSelected(c.id);
setPanel("video");
}
}}
>
{Array.from({ length: Math.min(24, Math.ceil(width / 88)) }, (_, j) => (
))}
{asset.name} {frameStamp(c.duration / SECOND)}
{selected === c.id && (
<>
dragTrim(e, i, "left")}
/>
dragTrim(e, i, "right")}
/>
>
)}
);
})}
{timeline.clips.slice(1).map(
(c, i) =>
(transitionsOpen || c.overlap > 0) && (
),
)}
{timeline.audioClips.map((c) => (
a.id === c.assetId)?.name}`}
className={`se-audio-clip ${audioId === c.id ? "selected" : ""}`}
style={{ left: (c.start / SECOND) * pps, width: (c.duration / SECOND) * pps }}
onPointerDown={(e) => dragAudio(e, c)}
onClick={() => {
setAudioId(c.id);
setSelected(undefined);
setPanel("audio");
}}
onContextMenu={(e) => {
setAudioId(c.id);
setSelected(undefined);
setPanel("audio");
openContext(e, "audio");
}}
>
{plan.assets.find((a) => a.id === c.assetId)?.name}{" "}
{frameStamp(c.duration / SECOND)}
{audioId === c.id && (
<>
dragAudio(e, c, "left")}
/>
dragAudio(e, c, "right")}
/>
>
)}
))}
{context && (
e.stopPropagation()}
>
{context.kind === "clip" && (
)}
{context.kind !== "timeline" && (
)}
)}
);
}
function StyleNumber({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (v: number) => void;
}) {
return (
);
}
function StyleColor({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
);
}
function ValueField({
label,
suffix,
value,
min,
max,
precision,
onChange,
}: {
label: string;
suffix: string;
value: number;
min: number;
max: number;
precision: number;
onChange: (v: number) => void;
}) {
const [text, setText] = useState(value.toFixed(precision));
useEffect(() => setText(value.toFixed(precision)), [value, precision]);
const commit = () => {
const v = Number(text);
if (text.trim() && Number.isFinite(v)) onChange(clamp(v, min, max));
else setText(value.toFixed(precision));
};
return (
);
}
function CutIcon({ side }: { side?: "left" | "right" }) {
return (
);
}
function TransitionIcon() {
return (
);
}
export function SmartEditEmptyEditor({
onClose,
onConfigure,
onRetry,
loading,
message,
}: {
onClose: () => void;
onConfigure: () => void;
onRetry: () => void;
loading: boolean;
message: string;
}) {
return (
{message}
{!loading && (
{/失败|错误|中断/.test(message) && (
)}
)}
);
}