From bbac6289b3403b66c71f69c92953b1925176e9ed Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 17 Sep 2026 16:32:04 +0800 Subject: [PATCH] Add smart editing workflow and isolated durable video rendering service --- .gitignore | 7 + app/api/canvas/generation-tasks/route.ts | 6 + .../canvas/smart-edit/capabilities/route.ts | 47 + .../canvas/LiblibVideoCanvasReplica.tsx | 42 +- components/canvas/smart-edit/EditPreview.tsx | 149 +++ .../canvas/smart-edit/SmartEditDialog.tsx | 910 ++++++++++++++++++ ...o-canvas-smart-edit-research-2026-09-17.md | 5 + docs/video-render-service-2026-09-17.md | 28 + lib/video-canvas/smart-edit/contract.ts | 258 +++++ lib/video-canvas/smart-edit/execute.ts | 298 ++++++ lib/video-canvas/smart-edit/local.ts | 196 ++++ lib/video-canvas/smart-edit/remote-render.ts | 146 +++ lib/video-canvas/smart-edit/render-job.ts | 43 + lib/video-canvas/smart-edit/render.ts | 153 +++ lib/video-canvas/smart-edit/semantic.ts | 56 ++ lib/video-canvas/task-contract.ts | 30 +- lib/video-canvas/task-recovery.ts | 28 +- lib/video-canvas/task-worker.ts | 18 +- next.config.ts | 4 + playwright.helpers.config.ts | 1 + scripts/qa/fixtures/smart-edit.py | 8 + scripts/qa/video-canvas-smart-edit.cjs | 158 +++ scripts/qa/video-canvas-task-db.cjs | 1 + scripts/qa/video-render-service.cjs | 124 +++ services/smart-edit/README.md | 24 + services/smart-edit/analyze.py | 87 ++ services/smart-edit/prepare_models.py | 13 + services/smart-edit/requirements-asr.txt | 5 + services/smart-edit/requirements.txt | 6 + services/smart-edit/setup.sh | 12 + services/video-render/README.md | 41 + services/video-render/build.cjs | 19 + services/video-render/server.ts | 350 +++++++ .../video-render/video-render-tunnel.service | 17 + services/video-render/video-render.service | 32 + tests/routes/video-canvas-smart-edit.test.ts | 171 ++++ 36 files changed, 3481 insertions(+), 12 deletions(-) create mode 100644 app/api/canvas/smart-edit/capabilities/route.ts create mode 100644 components/canvas/smart-edit/EditPreview.tsx create mode 100644 components/canvas/smart-edit/SmartEditDialog.tsx create mode 100644 docs/video-render-service-2026-09-17.md create mode 100644 lib/video-canvas/smart-edit/contract.ts create mode 100644 lib/video-canvas/smart-edit/execute.ts create mode 100644 lib/video-canvas/smart-edit/local.ts create mode 100644 lib/video-canvas/smart-edit/remote-render.ts create mode 100644 lib/video-canvas/smart-edit/render-job.ts create mode 100644 lib/video-canvas/smart-edit/render.ts create mode 100644 lib/video-canvas/smart-edit/semantic.ts create mode 100644 scripts/qa/fixtures/smart-edit.py create mode 100644 scripts/qa/video-canvas-smart-edit.cjs create mode 100644 scripts/qa/video-render-service.cjs create mode 100644 services/smart-edit/README.md create mode 100644 services/smart-edit/analyze.py create mode 100644 services/smart-edit/prepare_models.py create mode 100644 services/smart-edit/requirements-asr.txt create mode 100644 services/smart-edit/requirements.txt create mode 100755 services/smart-edit/setup.sh create mode 100644 services/video-render/README.md create mode 100644 services/video-render/build.cjs create mode 100644 services/video-render/server.ts create mode 100644 services/video-render/video-render-tunnel.service create mode 100644 services/video-render/video-render.service create mode 100644 tests/routes/video-canvas-smart-edit.test.ts diff --git a/.gitignore b/.gitignore index b4e644c00..bdc5ded58 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,10 @@ public/vendor/zoo-modeling-app/ # Drizzle migrations must be versioned (override the global *.sql ignore above) !lib/db/migrations/*.sql + +# Private smart edit runtime and media/model caches +.venv-smart-edit/ +.smart-edit-models/ +.cache/smart-edit/ + +services/video-render/dist/ diff --git a/app/api/canvas/generation-tasks/route.ts b/app/api/canvas/generation-tasks/route.ts index da41018a6..056ac2819 100644 --- a/app/api/canvas/generation-tasks/route.ts +++ b/app/api/canvas/generation-tasks/route.ts @@ -1,3 +1,4 @@ +import { smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract"; import { audioRequestSchema } from "@/lib/video-canvas/audio-contract"; import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery"; import { imageOperationSchema } from "@/lib/video-canvas/image-operation"; @@ -55,6 +56,11 @@ export async function POST(request: Request) { throw Object.assign(Error("脚本结果类型不匹配"), { status: 400 }); if (parsed.data.kind === "audio" && !audioRequestSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("音频参数不合法"), { status: 400 }); + if ( + parsed.data.kind === "smart-edit" && + !smartEditRequestSchema.safeParse(parsed.data.params).success + ) + throw Object.assign(Error("剪辑参数无效"), { status: 400 }); const userId = await owner(parsed.data.projectId); const task = await createCanvasGenerationTask(userId, parsed.data); kick(userId, parsed.data.projectId); diff --git a/app/api/canvas/smart-edit/capabilities/route.ts b/app/api/canvas/smart-edit/capabilities/route.ts new file mode 100644 index 000000000..b67185b4b --- /dev/null +++ b/app/api/canvas/smart-edit/capabilities/route.ts @@ -0,0 +1,47 @@ +import { auth } from "@/app/(auth)/auth"; +import { NextResponse } from "next/server"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import path from "node:path"; +import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; +import { isCanvasVisionModel } from "@/lib/video-canvas/model-matching"; +import { remoteRenderConfig } from "@/lib/video-canvas/smart-edit/remote-render"; +export async function GET() { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 }); + try { + let capabilities = { scenes: false, beats: false, asr: false, ffmpeg: false, ffprobe: false }; + try { + const { stdout } = await promisify(execFile)( + process.env.SMART_EDIT_PYTHON || "python3", + [path.resolve("services/smart-edit/analyze.py"), "--check"], + { timeout: 10000, maxBuffer: 65536 }, + ); + capabilities = JSON.parse(stdout); + } catch {} + let renderAvailable = capabilities.ffmpeg; + const remote = remoteRenderConfig(); + if (remote) { + try { + const response = await fetch(remote.base + "/health", { + headers: { authorization: "Bearer " + remote.token }, + signal: AbortSignal.timeout(3000), + redirect: "error", + cache: "no-store", + }); + renderAvailable = response.ok && (await response.json()).status === "ok"; + } catch { + renderAvailable = false; + } + } + const models = (await getEnabledAiModelsWithProvider(false)) + .filter((item) => isCanvasVisionModel({ ...item.model, providerType: item.provider.type })) + .map(({ model }) => ({ id: model.id, name: model.displayName, modelId: model.modelId })); + return NextResponse.json( + { capabilities: { ...capabilities, render: renderAvailable }, models }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch { + return NextResponse.json({ error: "剪辑服务配置读取失败" }, { status: 503 }); + } +} diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 99907f96a..a64a8915c 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -1,4 +1,6 @@ "use client"; +import { SmartEditDialog } from "./smart-edit/SmartEditDialog"; +import { type EditPlan } from "@/lib/video-canvas/smart-edit/contract"; import { AudioGenerationComposer } from "./AudioGenerationComposer"; import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice"; import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story"; @@ -444,7 +446,7 @@ type CanvasModelOption = { -type AddModuleKind = "text" | "image" | "video" | "video-clip" | "director" | "audio" | "script" | "legacy-script"; +type AddModuleKind = "smart-edit" | "text" | "image" | "video" | "video-clip" | "director" | "audio" | "script" | "legacy-script"; type CanvasCreateKind = AddModuleKind; type TargetModuleActionLabel = | "自己编写内容" @@ -461,6 +463,7 @@ type TargetModuleActionLabel = | "首帧生成视频" | "自己编写分镜脚本" | "生成高清视频" + | "打开智能剪辑" | "打开导演台" | "剧本生成分镜脚本" | "视频参考生成分镜脚本" @@ -738,6 +741,7 @@ const ADD_NODE_MODULES: Array<{ { kind: "text", label: "文本", description: "剧本、广告词、品牌文案", icon: FileText }, { kind: "image", label: "图片", description: "海报、分镜、角色设计", icon: ImageIcon }, { kind: "video", label: "视频", description: "创意广告、动画、电影", icon: Video }, + { kind: "smart-edit", label: "智能剪辑", description: "自动选片、卡点、字幕与成片", icon: Scissors, badge: "NEW" }, { kind: "video-clip", label: "视频合成", description: "多个视频片段合为一个", icon: Scissors, badge: "Beta" }, { kind: "director", label: "导演台", description: "3D空间多视角截图", icon: Settings2, badge: "NEW" }, { kind: "audio", label: "音频", description: "音效、配音、音乐", icon: Music2 }, @@ -764,11 +768,13 @@ const CONNECTION_ADD_MODULES: Array<{ { kind: "text", label: "文本生成", icon: FileText }, { kind: "image", label: "图片生成", icon: ImageIcon }, { kind: "video", label: "视频生成", icon: Video }, + { kind: "smart-edit", label: "智能剪辑", icon: Scissors }, { kind: "video-clip", label: "视频合成", icon: Scissors }, { kind: "script", label: "脚本生成", icon: Layers }, ]; const TARGET_MODULE_CENTER_OFFSETS: Record = { + "smart-edit": { x: 728, y: 670 }, audio: { x: 705, y: -257 }, image: { x: 53, y: -257 }, "legacy-script": { x: 556, y: 253 }, @@ -1592,6 +1598,9 @@ function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index }; } + if (kind === "smart-edit") { + return {id:makeId("smart-edit"),kind:"model",title:`智能剪辑 ${index}`,subtitle:"智能剪辑",inputType:"video",x,y,width:350,height:350,accent:"#ffb8c8",draftValues:{action:"SMART_EDIT_WORKFLOW",nodeType:"smart-edit"}}; + } if (kind === "video-clip") { return { id: makeId("video-clip"), @@ -1636,7 +1645,7 @@ function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index }; } - const meta: Record, { + const meta: Record, { action: string; accent: string; kind: CloneNodeKind; @@ -4110,6 +4119,7 @@ function getTargetModuleKind(node: CloneNode): AddModuleKind | null { nodeType === "text" || nodeType === "image" || nodeType === "video" || + nodeType === "smart-edit" || nodeType === "video-clip" || nodeType === "director" || nodeType === "audio" || @@ -4629,6 +4639,8 @@ export function LiblibVideoCanvasReplica({ const [cancelGenerationConfirmNodeId, setCancelGenerationConfirmNodeId] = useState(null); const [storyWorkflowNodeId, setStoryWorkflowNodeId] = useState(null); const [storyWorkflowStep, setStoryWorkflowStep] = useState("shots"); + const [smartEditNodeId,setSmartEditNodeId]=useState(null); + const smartEditNode=nodes.find(node=>node.id===smartEditNodeId); const [directorConsoleNodeId, setDirectorConsoleNodeId] = useState(null); const [directorTutorialActive, setDirectorTutorialActive] = useState(false); const [angleEditorNodeId, setAngleEditorNodeId] = useState(null); @@ -5950,6 +5962,9 @@ export function LiblibVideoCanvasReplica({ }; switch (actionLabel) { + case "打开智能剪辑": { + setSmartEditNodeId(nodeId);setSelectedNodeId(nodeId);setCanvasCommand(null);setNodeContextMenu(null);setConnectionMenu(null);return; + } case "打开导演台": { setDirectorConsoleNodeId(nodeId); setSelectedNodeId(nodeId); @@ -8723,6 +8738,7 @@ export function LiblibVideoCanvasReplica({ setNodeContextMenu(null); setConnectionMenu(null); + if (getTargetModuleKind(node) === "smart-edit") { setSmartEditNodeId(node.id); return; } if (node.inputType === "audio" && node.draftValues?.audioParams) { await generationTasks.submit( node, "audio", node.draftValues.audioParams as Record, @@ -10959,6 +10975,24 @@ export function LiblibVideoCanvasReplica({ ) : null} + {smartEditNode ? |undefined} + plans={smartEditNode.draftValues?.smartEditPlans as EditPlan[]|undefined} + planTaskId={getDraftString(smartEditNode,"smartEditPlanTaskId")} + preferredModelRef={videoCanvasFunctionConfig.defaultMultimodalModel} + sources={nodes.filter(node=>node.inputType==="video"&&getNodeReferenceMediaUrl(node)).map(node=>({id:node.id,name:node.title,url:getNodeReferenceMediaUrl(node),subtitles:[]}))} + audios={nodes.filter(node=>node.inputType==="audio"&&getNodeReferenceMediaUrl(node)).map(node=>({name:node.title,url:getNodeReferenceMediaUrl(node)}))} + tasks={generationTasks.tasks.filter(task=>task.request.kind==="smart-edit"&&(task.request.node.id===smartEditNode.id||task.request.params.sourceNodeId===smartEditNode.id))} + onClose={()=>setSmartEditNodeId(null)} + onDraft={smartEditDraft=>setNodes(current=>current.map(node=>node.id===smartEditNode.id?{...node,draftValues:{...node.draftValues,smartEditDraft}}:node))} + onPlan={params=>generationTasks.submit({...smartEditNode,draftValues:{action:"SMART_EDIT_WORKFLOW",nodeType:"smart-edit"}},"smart-edit",params,edges.filter(edge=>edge.target===smartEditNode.id))} + onRender={async plan=>{ + const result=createTargetModuleNode("video",smartEditNode.x+550,smartEditNode.y+generationTasks.tasks.filter(task=>task.request.params.sourceNodeId===smartEditNode.id).length*80,nodes.length+1); + result.title=`${plan.title} · 成片`; + await generationTasks.submit(result,"smart-edit",{operation:"render",plan,sourceNodeId:smartEditNode.id},[{id:makeId("edge"),source:smartEditNode.id,target:result.id}]); + }} + />:null} {directorConsoleNode ? ( > = { + "smart-edit": [{icon:Scissors,label:"打开智能剪辑"}], audio: [{ icon: Play, label: "音频生视频" }], director: [{ icon: Settings2, label: "打开导演台" }], image: [ @@ -18604,6 +18639,7 @@ function TargetModuleCanvasNode({ "video-clip": [], }; const isCompactTargetModule = + kind === "smart-edit" || kind === "text" || kind === "image" || kind === "video" || @@ -18650,7 +18686,7 @@ function TargetModuleCanvasNode({ )} - {kind === "video-clip" ? ( + {kind === "smart-edit" ?

自动选片 · 音乐卡点 · 字幕 · 多素材时间轴

: kind === "video-clip" ? (
空空如也,请连接视频节点后操作
diff --git a/components/canvas/smart-edit/EditPreview.tsx b/components/canvas/smart-edit/EditPreview.tsx new file mode 100644 index 000000000..b74c757e1 --- /dev/null +++ b/components/canvas/smart-edit/EditPreview.tsx @@ -0,0 +1,149 @@ +"use client"; +import { useEffect, useRef } from "react"; +import { + compiledSubtitles, + planTimeline, + SECOND, + type EditPlan, +} from "@/lib/video-canvas/smart-edit/contract"; +export function EditPreview({ + plan, + time, + playing, + onTime, + onPlaying, + onError, +}: { + plan: EditPlan; + time: number; + playing: boolean; + onTime: (t: number) => void; + onPlaying: (v: boolean) => void; + onError: (s: string) => void; +}) { + const videos = useRef(new Map()), + music = useRef(null), + latest = useRef({ time, plan, onTime, onPlaying }); + latest.current = { time, plan, onTime, onPlaying }; + const timeline = planTimeline(plan), + current = timeline.clips.filter((c) => time * SECOND >= c.start && time * SECOND < c.end), + subs = compiledSubtitles(plan).filter((s) => time * SECOND >= s.start && time * SECOND < s.end); + useEffect(() => { + if (!playing) return; + let frame = 0, + last = 0; + const tick = (now: number) => { + const state = latest.current; + if (last) { + const next = Math.min( + planTimeline(state.plan).duration / SECOND, + state.time + (now - last) / 1000, + ); + state.onTime(next); + if (next >= planTimeline(state.plan).duration / SECOND) { + state.onPlaying(false); + return; + } + } + last = now; + frame = requestAnimationFrame(tick); + }; + const hide = () => { + if (document.hidden) latest.current.onPlaying(false); + }; + frame = requestAnimationFrame(tick); + document.addEventListener("visibilitychange", hide); + return () => { + cancelAnimationFrame(frame); + document.removeEventListener("visibilitychange", hide); + }; + }, [playing]); + useEffect(() => { + for (const clip of timeline.clips) { + const video = videos.current.get(clip.id); + if (!video) continue; + const active = current.some((c) => c.id === clip.id); + if (!active) { + video.pause(); + continue; + } + const target = clip.sourceIn / SECOND + (time - clip.start / SECOND) * clip.speed; + if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.1) + video.currentTime = target; + video.playbackRate = clip.speed; + const index = timeline.clips.findIndex((c) => c.id === clip.id), + next = timeline.clips[index + 1]; + let gain = 1; + if (clip.overlap && time * SECOND < clip.start + clip.overlap) + gain = (time * SECOND - clip.start) / clip.overlap; + if (next?.overlap && time * SECOND > next.start) + gain = Math.min(gain, (clip.end - time * SECOND) / next.overlap); + video.volume = Math.min(1, Math.max(0, clip.gain * gain)); + if (playing) + void video.play().catch(() => { + onPlaying(false); + onError("预览播放失败,请检查素材链接或点击播放重试"); + }); + else video.pause(); + } + const audio = music.current; + if (audio && plan.music) { + const target = time + plan.music.start / SECOND; + if (Number.isFinite(audio.duration) && target >= audio.duration) { + audio.pause(); + return; + } + if (Math.abs(audio.currentTime - target) > 0.15) audio.currentTime = target; + audio.volume = Math.min(1, plan.music.gain); + if (playing) + void audio.play().catch(() => { + onPlaying(false); + onError("背景音乐无法播放"); + }); + else audio.pause(); + } + }, [time, playing, plan]); + return ( +
+ {timeline.clips.map((clip) => { + const asset = plan.assets.find((a) => a.id === clip.assetId)!; + const active = current.some((c) => c.id === clip.id), + incoming = clip.overlap && time * SECOND < clip.start + clip.overlap; + return ( +
+ ); +} diff --git a/components/canvas/smart-edit/SmartEditDialog.tsx b/components/canvas/smart-edit/SmartEditDialog.tsx new file mode 100644 index 000000000..a04971e92 --- /dev/null +++ b/components/canvas/smart-edit/SmartEditDialog.tsx @@ -0,0 +1,910 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { X, Upload } from "lucide-react"; +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 }; +export function SmartEditDialog({ + initial, + plans: serverPlans, + planTaskId, + sources, + audios, + preferredModelRef, + tasks, + onClose, + onDraft, + onPlan, + onRender, +}: { + initial?: Record; + plans?: EditPlan[]; + planTaskId?: string; + sources: Asset[]; + audios: { url: string; name: string }[]; + preferredModelRef?: string; + tasks: CanvasGenerationTask[]; + onClose: () => void; + onDraft: (draft: Record) => void; + onPlan: (params: SmartEditRequest) => Promise; + onRender: (plan: EditPlan) => Promise; +}) { + const [draft, setDraft] = useState>(() => ({ + assets: [], + mode: "montage", + prompt: "", + targetDuration: 20, + 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) => { + const next = { ...latest.current, ...value }; + latest.current = next; + setDraft(next); + onDraft(next); + }; + const [selected, setSelected] = useState(0), + [clipIndex, setClipIndex] = useState(0), + [time, setTime] = useState(0), + [playing, setPlaying] = useState(false), + [busy, setBusy] = useState(false), + [error, setError] = useState(""), + [cap, setCap] = useState(), + [zoom, setZoom] = useState(1), + [snap, setSnap] = useState(true); + const applied = useRef(planTaskId); + useEffect(() => { + if (planTaskId && planTaskId !== applied.current) { + applied.current = planTaskId; + patch({ plans: serverPlans || [], planTaskId }); + setSelected(0); + setTime(0); + setPlaying(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 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 | undefined, + validation = ""; + try { + if (plan) timeline = planTimeline(plan); + } catch (e) { + validation = (e as Error).message; + } + const changePlan = (next: EditPlan) => { + setPlaying(false); + patch({ 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 Error("单个素材不得超过 125 MiB"); + const form = new FormData(); + form.append("file", file); + const response = await fetch("/api/canvas/media/upload", { + method: "POST", + body: form, + signal: AbortSignal.timeout(120000), + }); + const result = await response.json(); + const url = result.url || result.data?.url; + if (!response.ok || !url) throw 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) => { + 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"; + return ( +
+
+

智能剪辑 · {plan?.title || "从素材到成片"}

+ +
+
+