ai-chatbot/components/canvas/smart-edit/SmartEditDialog.tsx

1111 lines
42 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 { useEffect, useRef, useState } from "react";
import { X, Upload } from "lucide-react";
import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery";
import { SmartEditEditor, SmartEditEmptyEditor } from "./SmartEditEditor";
import { assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
import { EditPreview } from "./EditPreview";
import {
smartEditRequestSchema,
planTimeline,
parseSrt,
subtitlesToSrt,
compiledSubtitles,
SECOND,
type EditPlan,
type SmartEditRequest,
} from "@/lib/video-canvas/smart-edit/contract";
import type { CanvasGenerationTask } from "@/lib/video-canvas/task-contract";
type Asset = { id: string; url: string; name: string; subtitles: ReturnType<typeof parseSrt> };
export function SmartEditDialog({
initial,
plans: serverPlans,
planTaskId,
sources,
connectedSourceIds = [],
audios,
preferredModelRef,
tasks,
onClose,
onDraft,
onPlan,
onRender,
onCancel,
}: {
initial?: Record<string, any>;
plans?: EditPlan[];
planTaskId?: string;
sources: Asset[];
connectedSourceIds?: string[];
audios: { url: string; name: string }[];
preferredModelRef?: string;
tasks: CanvasGenerationTask[];
onClose: () => void;
onDraft: (draft: Record<string, unknown>) => void;
onPlan: (params: SmartEditRequest) => Promise<unknown>;
onRender: (plan: EditPlan) => Promise<unknown>;
onCancel?: (taskId: string) => Promise<unknown>;
}) {
const [draft, setDraft] = useState<Record<string, any>>(() => ({
assets: [],
mode: "auto",
prompt: "",
targetDuration: 30,
ratio: "16:9",
resolution: "720p",
semantic: true,
removeSilence: false,
transcribe: false,
beatSync: false,
beatsPerCut: "4",
variants: 3,
preferredModelRef,
...initial,
plans:
initial?.planTaskId === planTaskId
? initial?.plans || serverPlans || []
: serverPlans || [],
planTaskId,
}));
const latest = useRef(draft);
latest.current = draft;
const patch = (value: Record<string, unknown>) => {
const next = { ...latest.current, ...value };
latest.current = next;
setDraft(next);
onDraft(next);
};
const [selected, setSelected] = useState(() =>
Math.max(
0,
Math.min((draft.plans?.length || 1) - 1, Number(initial?.selectedPlanIndex) || 0),
),
),
[clipIndex, setClipIndex] = useState(0),
[time, setTime] = useState(0),
[playing, setPlaying] = useState(false),
[busy, setBusy] = useState(false),
[error, setError] = useState(""),
[cap, setCap] = useState<any>(),
[zoom, setZoom] = useState(1),
[snap, setSnap] = useState(true);
const [configure, setConfigure] = useState(false);
const [videoDragActive, setVideoDragActive] = useState(false);
const videoDragDepthRef = useRef(0);
const uploadVideoFiles = async (files: File[]) => {
if (!files.length) return;
if (files.length + draft.assets.length > 12) {
setError("最多 12 个素材");
return;
}
setBusy(true);
try {
for (const file of files) await upload(file, "video");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const applied = useRef(planTaskId);
useEffect(() => {
if (planTaskId && planTaskId !== applied.current) {
applied.current = planTaskId;
const processing = tasks.some(
(t) =>
t.id === planTaskId &&
["separate", "transcribe"].includes(String(t.request.params.operation)),
);
const index = processing ? Math.min(selected, (serverPlans?.length || 1) - 1) : 0;
patch({ plans: serverPlans || [], planTaskId, selectedPlanIndex: index });
setSelected(index);
setTime(0);
setPlaying(false);
setConfigure(false);
}
}, [planTaskId, serverPlans]);
useEffect(() => {
const controller = new AbortController();
fetch("/api/canvas/smart-edit/capabilities", { signal: controller.signal })
.then(async (r) => {
const data = await r.json();
if (!r.ok) throw new Error(data.error);
setCap(data);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
});
return () => controller.abort();
}, []);
const waiting = tasks.some((t) => t.status === "pending" || t.status === "running"),
last = tasks.slice(-1)[0];
const plan = draft.plans?.[selected] as EditPlan | undefined;
let timeline: ReturnType<typeof planTimeline> | undefined,
validation = "";
try {
if (plan) timeline = planTimeline(plan);
} catch (e) {
validation = (e as Error).message;
}
const autoPrepared = useRef(false);
const startSequence = async () => {
const assets = [...new Set(connectedSourceIds)]
.map((id) => sources.find((a) => a.id === id))
.filter((a): a is Asset => Boolean(a));
if (!assets.length) return;
autoPrepared.current = true;
setBusy(true);
setError("");
try {
const params = smartEditRequestSchema.parse({
operation: "plan",
assets,
mode: "sequence",
targetDuration: 300,
semantic: false,
ratio: draft.ratio,
resolution: draft.resolution,
});
patch({ assets });
const preparationRequestId = await onPlan(params);
patch({ preparationRequestId });
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
useEffect(() => {
if (
!plan &&
!configure &&
!autoPrepared.current &&
!draft.preparationRequestId &&
!waiting &&
connectedSourceIds.length
)
void startSequence();
}, [Boolean(plan), configure, connectedSourceIds.join("|"), waiting]);
const changePlan = (next: EditPlan) => {
setPlaying(false);
patch({
selectedPlanIndex: selected,
plans: draft.plans.map((p: EditPlan, i: number) => (i === selected ? next : p)),
});
};
const saveFile = (name: string, text: string) => {
const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }));
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const upload = async (file: File, kind: "video" | "audio") => {
if (file.size > 125 * 1024 * 1024) throw new Error("单个素材不得超过 125 MiB");
const form = new FormData();
form.append("file", file);
let response: Response;
try {
response = await fetch("/api/canvas/media/upload", {
method: "POST",
body: form,
signal: AbortSignal.timeout(600000),
});
} catch (e) {
throw new Error(
e instanceof DOMException && (e.name === "TimeoutError" || e.name === "AbortError")
? "上传超时,请检查网络后重试,或改用更小的素材"
: "上传失败,请检查网络后重试",
);
}
const result = await response.json();
const url = result.url || result.data?.url;
if (!response.ok || !url) throw new Error(result.error || "上传失败");
if (kind === "audio") patch({ music: { url, gain: 0.2, start: 0 } });
else
patch({
assets: [
...latest.current.assets,
{ id: crypto.randomUUID(), name: file.name, url, subtitles: [] },
],
});
};
const clipEdit = (index: number, value: Record<string, unknown>) => {
if (plan)
changePlan({
...plan,
clips: plan.clips.map((c, i) => (i === index ? { ...c, ...value } : c)),
});
};
const cut = (action: "split" | "left" | "right") => {
if (!plan || !timeline) return;
const clip = timeline.clips[clipIndex];
if (!clip) return;
const point = Math.round(clip.sourceIn + (time * SECOND - clip.start) * clip.speed);
if (point - clip.sourceIn < 200000 || clip.sourceOut - point < 200000) {
setError("请把播放头移到片段内部,并在两边至少保留 0.2 秒");
return;
}
if (action === "split") {
if (plan.clips.length >= 12) {
setError("最多 12 个片段");
return;
}
const left = { ...plan.clips[clipIndex], sourceOut: point },
right = { ...plan.clips[clipIndex], id: crypto.randomUUID(), sourceIn: point };
changePlan({
...plan,
clips: [
...plan.clips.slice(0, clipIndex),
left,
right,
...plan.clips.slice(clipIndex + 1),
],
});
} else clipEdit(clipIndex, action === "left" ? { sourceIn: point } : { sourceOut: point });
};
const button =
"rounded border border-white/20 px-3 py-2 text-xs hover:bg-white/10 disabled:opacity-40",
input = "rounded border border-white/15 bg-[#303030] px-2 py-1 text-sm";
if (!plan && !configure)
return (
<SmartEditEmptyEditor
onClose={onClose}
onConfigure={() => setConfigure(true)}
onRetry={() => void startSequence()}
loading={busy || waiting || Boolean(draft.preparationRequestId && !last)}
message={
error ||
last?.error ||
last?.result?.smartEdit?.stage ||
(connectedSourceIds.length ? "读取已连接的视频素材" : "连接视频素材后开始剪辑")
}
/>
);
const audioTask = [...tasks]
.reverse()
.find((t) => ["separate", "transcribe"].includes(String(t.request.params.operation)));
const audioRecovering =
audioTask?.status === "interrupted" && hasRecoverableCanvasTask(audioTask);
if (audioTask && (["pending", "running"].includes(audioTask.status) || audioRecovering))
return (
<SmartEditEmptyEditor
onClose={onClose}
onConfigure={() => {}}
onRetry={() => {}}
loading
message={
audioTask.result?.smartEdit?.stage || "等待音频处理,可退出剪辑,稍后重新打开查看结果"
}
/>
);
if (plan && timeline && !configure)
return (
<SmartEditEditor
key={`${planTaskId || "draft"}:${selected}`}
plan={plan}
onChange={changePlan}
onClose={onClose}
onConfigure={() => setConfigure(true)}
onRender={onRender}
onCancel={onCancel}
canSeparate={Boolean(cap?.capabilities?.separation)}
canTranscribe={Boolean(cap?.capabilities?.asr)}
processError={
audioTask &&
(["failed", "cancelled"].includes(audioTask.status) ||
(audioTask.status === "interrupted" && !audioRecovering))
? audioTask.error || "音频处理失败,原内容已保留"
: undefined
}
onProcess={(operation, plan, clipId, track, keep) =>
onPlan(smartEditRequestSchema.parse({ operation, plan, clipId, track, keep }))
}
tasks={tasks}
canRender={Boolean(cap?.capabilities?.render)}
exportState={draft.exportState}
onExportState={(exportState) => patch({ exportState })}
/>
);
return (
<div
role="dialog"
aria-modal="true"
aria-label="智能剪辑"
className="fixed inset-0 z-[160] flex flex-col bg-[#171717] text-white"
onDragEnter={(e) => {
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
e.preventDefault();
videoDragDepthRef.current += 1;
setVideoDragActive(true);
}}
onDragOver={(e) => {
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
videoDragDepthRef.current = Math.max(0, videoDragDepthRef.current - 1);
if (!videoDragDepthRef.current) setVideoDragActive(false);
}}
onDrop={(e) => {
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
e.preventDefault();
videoDragDepthRef.current = 0;
setVideoDragActive(false);
const files = Array.from(e.dataTransfer.files).filter((file) =>
file.type.startsWith("video/"),
);
if (!files.length) {
setError("请拖入 MP4、MOV 或 WebM 视频素材");
return;
}
void uploadVideoFiles(files);
}}
>
{videoDragActive ? (
<div className="pointer-events-none fixed inset-0 z-[300] flex items-center justify-center border-4 border-dashed border-cyan-300/70 bg-cyan-300/5">
<span className="rounded-lg bg-black/70 px-4 py-2 text-sm">
松开以上传视频素材
</span>
</div>
) : null}
<header className="flex shrink-0 items-center justify-between border-b border-white/15 px-5 py-3">
<h2>智能剪辑 · {plan?.title || "从素材到成片"}</h2>
{plan && (
<button type="button" onClick={() => setConfigure(false)}>
返回时间轴
</button>
)}
<button type="button" aria-label="关闭智能剪辑" onClick={onClose}>
<X />
</button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(0,1fr)]">
<aside className="overflow-y-auto border-r border-white/15 p-4">
<fieldset disabled={busy || waiting} className="space-y-4">
<label className="block">
剪辑类型
<select
aria-label="剪辑类型"
className={`${input} mt-1 w-full`}
value={draft.mode}
onChange={(e) => patch({ mode: e.target.value })}
>
<option value="auto">默认模式</option>
<option value="sequence">顺序编辑</option>
<option value="montage">素材混剪</option>
<option value="talk">口播视频</option>
<option value="explain">讲解视频</option>
<option value="ads">批量广告</option>
</select>
</label>
<div>
<div className="mb-2 text-sm">素材({draft.assets.length}/12)</div>
<select
aria-label="添加画布视频"
className={`${input} w-full`}
value=""
onChange={(e) => {
const asset = sources.find((a) => a.id === e.target.value);
if (asset && draft.assets.length < 12)
patch({ assets: [...draft.assets, asset] });
}}
>
<option value="">选择画布已有视频</option>
{sources
.filter((a) => !draft.assets.some((v: Asset) => v.id === a.id))
.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<label className={`${button} mt-2 inline-flex cursor-pointer gap-1`}>
<Upload size={14} />
上传视频
<input
type="file"
accept="video/*"
multiple
className="hidden"
onChange={async (e) => {
const files = Array.from(e.target.files || []);
e.target.value = "";
await uploadVideoFiles(files);
}}
/>
</label>
{draft.assets.map((a: Asset) => (
<div key={a.id} className="mt-2 rounded border border-white/10 p-2 text-xs">
<div className="break-all">{a.name}</div>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() =>
patch({ assets: draft.assets.filter((x: Asset) => x.id !== a.id) })
}
>
移除
</button>
<label className="cursor-pointer">
导入 SRT
<input
type="file"
accept=".srt"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
const subtitles = parseSrt(await file.text());
patch({
assets: latest.current.assets.map((x: Asset) =>
x.id === a.id ? { ...x, subtitles } : x,
),
});
} catch (e) {
setError((e as Error).message);
}
}}
/>
</label>
<span>{a.subtitles?.length || 0} 条字幕</span>
</div>
</div>
))}
</div>
<label className="block">
剪辑要求
<textarea
aria-label="剪辑要求"
className={`${input} mt-1 w-full`}
rows={3}
maxLength={3000}
value={draft.prompt}
onChange={(e) => patch({ prompt: e.target.value })}
/>
</label>
<div className="grid grid-cols-2 gap-2">
<label>
目标秒数
<input
aria-label="目标秒数"
className={`${input} w-full`}
type="number"
min={3}
max={300}
value={draft.targetDuration}
onChange={(e) => patch({ targetDuration: Number(e.target.value) })}
/>
</label>
<label>
比例
<select
aria-label="成片比例"
className={`${input} w-full`}
value={draft.ratio}
onChange={(e) => patch({ ratio: e.target.value })}
>
{["21:9", "16:9", "9:16", "1:1", "4:3", "3:4"].map((v) => (
<option key={v}>{v}</option>
))}
</select>
</label>
<label>
分辨率
<select
aria-label="成片分辨率"
className={`${input} w-full`}
value={draft.resolution}
onChange={(e) => patch({ resolution: e.target.value })}
>
<option>720p</option>
<option>1080p</option>
</select>
</label>
{draft.mode === "ads" ? (
<label>
版本数
<select
aria-label="广告版本数"
className={`${input} w-full`}
value={draft.variants}
onChange={(e) => patch({ variants: Number(e.target.value) })}
>
{[1, 2, 3].map((v) => (
<option key={v}>{v}</option>
))}
</select>
</label>
) : null}
</div>
<label className="flex gap-2 text-sm">
<input
type="checkbox"
checked={draft.semantic}
onChange={(e) => patch({ semantic: e.target.checked })}
/>
千问智能选片
</label>
{draft.semantic ? (
<select
aria-label="智能选片模型"
className={`${input} w-full`}
value={draft.preferredModelRef || ""}
onChange={(e) => patch({ preferredModelRef: e.target.value })}
>
<option value="">默认千问3.8</option>
{cap?.models?.map((m: any) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
) : null}
<label className="flex gap-2 text-sm">
<input
type="checkbox"
checked={draft.removeSilence}
onChange={(e) => patch({ removeSilence: e.target.checked })}
/>
去静音粗剪(保留语句余量)
</label>
<label className="flex gap-2 text-sm">
<input
type="checkbox"
disabled={!cap?.capabilities?.asr}
checked={draft.transcribe}
onChange={(e) => patch({ transcribe: e.target.checked })}
/>
自动语音转写
</label>
{cap && !cap.capabilities.asr ? (
<p className="text-xs text-amber-200">
语音识别模型尚未配置,可为素材导入 SRT 字幕。
</p>
) : null}
<div>
<label>
背景音乐
<select
aria-label="背景音乐"
className={`${input} mt-1 w-full`}
value={draft.music?.url || ""}
onChange={(e) =>
patch({
music: e.target.value
? { url: e.target.value, gain: 0.2, start: 0 }
: undefined,
})
}
>
<option value="">不添加音乐</option>
{[
...audios,
...(draft.music && !audios.some((a) => a.url === draft.music.url)
? [{ url: draft.music.url, name: "已上传音乐" }]
: []),
].map((a) => (
<option key={a.url} value={a.url}>
{a.name}
</option>
))}
</select>
</label>
<label className={`${button} mt-2 inline-block cursor-pointer`}>
上传音乐
<input
type="file"
accept="audio/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setBusy(true);
try {
await upload(file, "audio");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
/>
</label>
</div>
<label className="flex gap-2 text-sm">
<input
type="checkbox"
checked={draft.beatSync}
disabled={!draft.music}
onChange={(e) => patch({ beatSync: e.target.checked })}
/>
跟随音乐卡点
</label>
{draft.beatSync ? (
<select
aria-label="每次切镜拍数"
className={input}
value={draft.beatsPerCut}
onChange={(e) => patch({ beatsPerCut: e.target.value })}
>
<option value="2">每两拍</option>
<option value="4">每四拍</option>
</select>
) : null}
{cap &&
((draft.mode !== "sequence" && !cap.capabilities.scenes) ||
!cap.capabilities.ffmpeg ||
!cap.capabilities.ffprobe) ? (
<p role="alert" className="text-sm text-amber-200">
剪辑处理环境尚未准备好,请配置本地分析服务。
</p>
) : null}
<button
type="button"
className={`${button} w-full bg-white/10`}
disabled={
!draft.assets.length ||
(draft.mode !== "sequence" && !cap?.capabilities.scenes) ||
!cap?.capabilities.ffmpeg ||
!cap?.capabilities.ffprobe
}
onClick={async () => {
setError("");
setBusy(true);
setPlaying(false);
try {
const params = smartEditRequestSchema.parse({ ...draft, operation: "plan" });
if (params.operation === "plan") assertBeatMusic(params);
await onPlan(params);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
>
{waiting ? "处理中…" : "生成剪辑方案"}
</button>
</fieldset>
</aside>
<main className="min-w-0 overflow-y-auto p-4">
{last ? (
<p role="status" className="mb-3 text-sm">
{last.status === "succeeded"
? "任务完成"
: last.status === "pending"
? "排队中"
: last.result?.smartEdit?.stage || last.status}
{last.error ? `:${last.error}` : ""}
</p>
) : null}
{error || validation ? (
<p role="alert" className="mb-3 rounded bg-red-950/50 p-3 text-sm">
{error || validation}
</p>
) : null}
{cap && !cap.capabilities.render ? (
<p className="mb-3 text-sm text-amber-200">
视频合成服务暂不可用,剪辑方案仍可保存。
</p>
) : null}
{plan ? (
<>
<div className="mb-3 flex flex-wrap items-center gap-2">
{draft.plans.map((p: EditPlan, i: number) => (
<button
type="button"
key={i}
className={`${button} ${selected === i ? "bg-white/15" : ""}`}
onClick={() => {
setSelected(i);
patch({ selectedPlanIndex: i });
setTime(0);
setPlaying(false);
setClipIndex(0);
}}
>
{p.title}
</button>
))}
<button
type="button"
className={button}
disabled={!timeline || busy || waiting || !cap?.capabilities?.render}
onClick={async () => {
setBusy(true);
setPlaying(false);
try {
await onRender(plan);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
>
导出到画布
</button>
{draft.plans.length > 1 ? (
<button
type="button"
className={button}
disabled={busy || waiting || !timeline || !cap?.capabilities?.render}
onClick={async () => {
setBusy(true);
try {
for (const p of draft.plans) await onRender(p);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
>
导出全部版本
</button>
) : null}
<button
type="button"
className={button}
onClick={() => saveFile("剪辑方案.json", JSON.stringify(plan, null, 2))}
>
下载剪辑方案
</button>
{timeline ? (
<button
type="button"
className={button}
onClick={() =>
saveFile("字幕.srt", subtitlesToSrt(compiledSubtitles(plan)))
}
>
下载字幕
</button>
) : null}
</div>
{timeline ? (
<>
<EditPreview
plan={plan}
time={time}
playing={playing}
onTime={setTime}
onPlaying={setPlaying}
onError={setError}
/>
<div className="my-3 flex flex-wrap items-center gap-3">
<button
type="button"
className={button}
onClick={() => {
if (time >= timeline!.duration / SECOND) setTime(0);
setPlaying(!playing);
}}
>
{playing ? "暂停预览" : "播放预览"}
</button>
<span className="text-sm">
{time.toFixed(2)} / {(timeline.duration / SECOND).toFixed(2)} 秒
</span>
<label className="text-sm">
<input
type="checkbox"
checked={snap}
onChange={(e) => setSnap(e.target.checked)}
/>
吸附切点
</label>
<label className="text-sm">
缩放{" "}
<select
aria-label="剪辑时间轴缩放"
className={input}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
>
{[1, 2, 4].map((v) => (
<option key={v}>{v}</option>
))}
</select>
</label>
</div>
<div className="overflow-x-auto">
<div style={{ minWidth: `${zoom * 100}%` }}>
<input
aria-label="剪辑播放头"
type="range"
className="w-full"
min={0}
max={Math.max(0, timeline.duration / SECOND - 0.001)}
step={1 / 30}
value={time}
onChange={(e) => {
let value = Number(e.target.value);
if (snap) {
const near = timeline!.clips
.map((c) => c.start / SECOND)
.find((t) => Math.abs(t - value) < 0.12);
if (near !== undefined) value = near;
}
setPlaying(false);
setTime(value);
}}
/>
<div className="flex h-12">
{timeline.clips.map((c, i) => (
<button
type="button"
className={`min-w-0 overflow-hidden border p-1 text-xs ${i === clipIndex ? "border-amber-300 bg-amber-400/20" : "border-white/20 bg-white/5"}`}
style={{ width: `${(c.duration / timeline!.duration) * 100}%` }}
key={c.id}
onClick={() => {
setClipIndex(i);
setTime(c.start / SECOND);
setPlaying(false);
}}
>
{i + 1} · {c.label || "片段"}
</button>
))}
</div>
</div>
</div>
</>
) : null}
<fieldset disabled={busy || waiting} className="mt-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
{(["split", "left", "right"] as const).map((v, i) => (
<button type="button" key={v} className={button} onClick={() => cut(v)}>
{["分割所选片段", "向左裁剪", "向右裁剪"][i]}
</button>
))}
<label className="text-sm">
转场{" "}
<select
aria-label="剪辑转场"
className={input}
value={plan.transition}
onChange={(e) =>
changePlan({ ...plan, transition: Number(e.target.value) })
}
>
<option value={0}>直接切换</option>
<option value={0.25}>叠化 0.25 秒</option>
<option value={0.5}>叠化 0.5 秒</option>
</select>
</label>
<label className="text-sm">
<input
type="checkbox"
checked={plan.subtitles}
onChange={(e) => changePlan({ ...plan, subtitles: e.target.checked })}
/>
字幕轨道
</label>
{plan.music ? (
<>
<label className="text-sm">
音乐音量{" "}
<input
aria-label="音乐音量"
type="number"
className={`${input} w-16`}
min={0}
max={1}
step={0.1}
value={plan.music.gain}
onChange={(e) =>
changePlan({
...plan,
music: { ...plan.music!, gain: Number(e.target.value) },
})
}
/>
</label>
<label className="text-sm">
音乐起点(秒){" "}
<input
aria-label="音乐起点"
type="number"
className={`${input} w-20`}
min={0}
step={0.1}
value={plan.music.start / SECOND}
onChange={(e) =>
changePlan({
...plan,
music: {
...plan.music!,
start: Math.round(Number(e.target.value) * SECOND),
},
})
}
/>
</label>
</>
) : null}
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr>
{["片段", "入点(秒)", "出点(秒)", "速度", "原声音量", "顺序"].map(
(t) => (
<th className="px-2 py-2" key={t}>
{t}
</th>
),
)}
</tr>
</thead>
<tbody>
{plan.clips.map((clip, i) => (
<tr key={clip.id} className={i === clipIndex ? "bg-white/10" : ""}>
<td className="max-w-48 px-2 py-2">
<button type="button" onClick={() => setClipIndex(i)}>
{i + 1} · {plan.assets.find((a) => a.id === clip.assetId)?.name}
</button>
</td>
{(["sourceIn", "sourceOut", "speed", "gain"] as const).map(
(key, j) => (
<td className="px-2 py-2" key={key}>
<input
aria-label={`片段${i + 1}${["入点", "出点", "速度", "音量"][j]}`}
type="number"
className={`${input} w-24`}
min={key === "speed" ? 0.5 : 0}
max={key === "speed" ? 2 : key === "gain" ? 1 : 180}
step={j < 2 ? 0.1 : 0.1}
value={j < 2 ? clip[key] / SECOND : clip[key]}
onChange={(e) =>
clipEdit(i, {
[key]:
j < 2
? Math.round(Number(e.target.value) * SECOND)
: Number(e.target.value),
})
}
/>
</td>
),
)}
<td className="whitespace-nowrap px-2 py-2">
{[-1, 1].map((delta) => (
<button
type="button"
key={delta}
className={button}
disabled={i + delta < 0 || i + delta >= plan.clips.length}
onClick={() => {
const clips = [...plan.clips];
[clips[i], clips[i + delta]] = [clips[i + delta], clips[i]];
changePlan({ ...plan, clips });
setClipIndex(i + delta);
}}
>
{delta < 0 ? "上移" : "下移"}
</button>
))}
<button
type="button"
className={button}
disabled={plan.clips.length === 1}
onClick={() => {
changePlan({
...plan,
clips: plan.clips.filter((_, j) => j !== i),
});
setClipIndex(0);
setTime(0);
}}
>
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<details>
<summary className="cursor-pointer text-sm">编辑原始字幕</summary>
{plan.assets.flatMap((asset) =>
asset.subtitles.map((s, i) => (
<label
className="mt-2 flex items-center gap-2 text-xs"
key={`${asset.id}-${i}`}
>
<span>
{asset.name} {(s.start / SECOND).toFixed(1)}s
</span>
<input
aria-label={`${asset.name}字幕${i + 1}`}
className={`${input} flex-1`}
value={s.text}
maxLength={500}
onChange={(e) =>
changePlan({
...plan,
assets: plan.assets.map((a) =>
a.id === asset.id
? {
...a,
subtitles: a.subtitles.map((old, j) =>
j === i ? { ...old, text: e.target.value } : old,
),
}
: a,
),
})
}
/>
</label>
)),
)}
</details>
</fieldset>
{plan.warnings.map((w, i) => (
<p className="mt-2 text-xs text-amber-200" key={i}>
{w}
</p>
))}
</>
) : (
<div className="p-12 text-center text-white/50">
添加素材后生成剪辑方案,在这里预览和调整时间轴。
</div>
)}
{tasks
.filter(
(t) =>
t.status === "succeeded" &&
t.request.params.operation === "render" &&
t.result?.url,
)
.map((t) => (
<section key={t.id} className="mt-5 rounded border border-white/15 p-3">
<div className="mb-2 text-sm">{t.request.node.title}</div>
<video controls src={String(t.result!.url)} className="max-h-72 w-full" />
<a
className={`${button} mt-2 inline-block`}
href={String(t.result!.url)}
target="_blank"
rel="noreferrer"
download
>
下载成片
</a>
</section>
))}
</main>
</div>
</div>
);
}