{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 === "clip" || context.kind === "audio") && (
{separationMenu && (
)}
)}
{context.kind !== "timeline" && (
)}
)}
);
}
function StyleNumber({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (v: number) => void;
}) {
const [draft, setDraft] = useState(String(Number(value.toFixed(2))));
useEffect(() => setDraft(String(Number(value.toFixed(2)))), [value]);
const commit = () => {
const n = Number(draft),
next = draft.trim() && Number.isFinite(n) ? clamp(n, min, max) : value;
setDraft(String(Number(next.toFixed(2))));
if (next !== value) onChange(next);
};
const spacing = label === "字间距";
return (
{!spacing && (
onChange(Number(e.target.value))}
/>
)}
{spacing &&
X}
setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
{!spacing && (
<>
{label.includes("不透明度") ? "%" : "px"}
>
)}
);
}
function StyleColor({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
);
}
function TransitionDuration({
value,
disabled,
onChange,
}: {
value: number;
disabled: boolean;
onChange: (value: number) => void;
}) {
const [text, setText] = useState(value.toFixed(1));
useEffect(() => setText(value.toFixed(1)), [value]);
const commit = () => {
const n = Number(text);
if (text.trim() && Number.isFinite(n)) {
const v = clamp(Math.round(n * 10) / 10, 0.2, 4);
setText(v.toFixed(1));
onChange(v);
} else setText(value.toFixed(1));
};
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)) {
const bounded = clamp(v, min, max);
setText(bounded.toFixed(precision));
onChange(bounded);
} else setText(value.toFixed(precision));
};
const step = suffix.endsWith("dB") ? 0.5 : suffix.endsWith("x") ? 0.01 : 0.1;
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) && (
)}
)}
);
}
function CaptionTime({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}) {
const format = (v: number) =>
`${stamp(v / SECOND)}.${String(Math.floor((v % SECOND) / 10000)).padStart(2, "0")}`;
const [draft, setDraft] = useState(format(value));
const cancelled = useRef(false);
useEffect(() => setDraft(format(value)), [value]);
const commit = () => {
if (cancelled.current) {
cancelled.current = false;
setDraft(format(value));
return;
}
const match = draft.trim().match(/^(?:(\d+):)?(\d{1,2})(?:\.(\d{1,3}))?$/);
if (!match || (match[1] && Number(match[2]) >= 60)) {
setDraft(format(value));
return;
}
const next = clamp(
Math.round(
(Number(match[1] || 0) * 60 + Number(match[2]) + Number(`0.${match[3] || 0}`)) * SECOND,
),
min,
max,
);
setDraft(format(next));
if (next !== value) onChange(next);
};
return (
e.stopPropagation()}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") {
cancelled.current = true;
setDraft(format(value));
e.currentTarget.blur();
}
}}
/>
);
}