- Starter/toolbox presets no longer seed fake generated results; demo fallback media URLs in tool actions now fail with honest guidance - Delete dead demo scene components, fake director console, demo history constants and unreachable submit toast fallbacks - Smart edit dialog accepts desktop drag-and-drop video files - Raise media upload client timeout to 10 minutes and surface friendly timeout errors instead of raw 'signal timed out'
1111 lines
42 KiB
TypeScript
1111 lines
42 KiB
TypeScript
"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: 20,
|
||
ratio: "16:9",
|
||
resolution: "1080p",
|
||
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>
|
||
);
|
||
}
|