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: 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>
|
||
);
|
||
}
|