Align smart edit canvas and timeline UI with reference workflow

This commit is contained in:
134 2026-09-17 18:05:17 +08:00
parent 41e2db9ca7
commit f25e78e570
19 changed files with 4043 additions and 134 deletions

View File

@ -1,6 +1,7 @@
"use client";
import { SmartEditDialog } from "./smart-edit/SmartEditDialog";
import { type EditPlan } from "@/lib/video-canvas/smart-edit/contract";
import { SmartEditNodeCard, SmartEditNodeComposer } from "./smart-edit/SmartEditNode";
import { smartEditRequestSchema, 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";
@ -1599,7 +1600,7 @@ 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"}};
return {id:makeId("smart-edit"),kind:"model",title:`智能剪辑 ${index}`,subtitle:"智能剪辑",inputType:"video",x,y,width:566,height:350,accent:"#ffb8c8",draftValues:{action:"SMART_EDIT_WORKFLOW",nodeType:"smart-edit"}};
}
if (kind === "video-clip") {
return {
@ -5421,7 +5422,7 @@ export function LiblibVideoCanvasReplica({
);
const addUploadedFilesAtPoint = useCallback(
(files: File[], center: { x: number; y: number }) => {
(files: File[], center: { x: number; y: number }, connectTargetId?: string) => {
if (files.length === 0) return;
const batch = files.slice(0, 10);
const newNodes = batch.map((file, index) => {
@ -5430,7 +5431,7 @@ export function LiblibVideoCanvasReplica({
y: Math.round(center.y - created.height / 2),
draftValues: { ...created.draftValues, uploadStatus: "uploading" } };
});
addNodes(newNodes, []);
addNodes(newNodes, connectTargetId ? newNodes.map(node => ({ id: makeId("edge"), source: node.id, target: connectTargetId })) : []);
setActivePanel(null);
setCanvasCommand(null);
setConnectionMenu(null);
@ -5906,6 +5907,16 @@ export function LiblibVideoCanvasReplica({
toast.error("未找到可连接的节点");
return false;
}
if (getTargetModuleKind(target) === "smart-edit") {
if (source.inputType !== "video" || !getNodeReferenceMediaUrl(source)) {
toast.info("智能剪辑需要已上传或已生成的视频素材");
return false;
}
if (edges.filter(edge => edge.target === targetId).length >= 12) {
toast.info("智能剪辑最多连接 12 份视频素材");
return false;
}
}
const exists = edges.some((edge) => edge.source === sourceId && edge.target === targetId);
if (exists) {
toast.info("这两个节点已经连接");
@ -5963,6 +5974,7 @@ export function LiblibVideoCanvasReplica({
switch (actionLabel) {
case "打开智能剪辑": {
setNodes(current => current.map(node => node.id === nodeId ? {...node,width:566,height:350} : node));
setSmartEditNodeId(nodeId);setSelectedNodeId(nodeId);setCanvasCommand(null);setNodeContextMenu(null);setConnectionMenu(null);return;
}
case "打开导演台": {
@ -9510,6 +9522,7 @@ export function LiblibVideoCanvasReplica({
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (smartEditNodeId) return;
const target = event.target as HTMLElement | null;
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
if (event.code === "Space") { event.preventDefault(); spacePanRef.current = true; return; }
@ -9562,7 +9575,7 @@ export function LiblibVideoCanvasReplica({
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", releasePan);
return () => { window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); window.removeEventListener("blur", releasePan); };
}, [createLineConnectState, deleteNode, deleteSelectedEdge, nodes, selectedEdgeId, selectedNodeId]);
}, [createLineConnectState, deleteNode, deleteSelectedEdge, nodes, selectedEdgeId, selectedNodeId, smartEditNodeId]);
const onPointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@ -10053,6 +10066,7 @@ export function LiblibVideoCanvasReplica({
connectionHoverActive={node.id === lineConnectState?.hoverNodeId}
key={node.id}
node={node}
smartEditPreviewUrl={getTargetModuleKind(node) === "smart-edit" ? getNodeReferenceMediaUrl(nodes.find(source => edges.some(edge => edge.target === node.id && edge.source === source.id) && source.inputType === "video") || node) : undefined}
selected={node.id === selectedNodeId}
onContextMenu={(event) => openNodeContext(event, node)}
onCancelGeneration={() => cancelGenerationNode(node.id)}
@ -10359,6 +10373,34 @@ export function LiblibVideoCanvasReplica({
</div>
) : null}
{selectedNode && selectedTargetModuleKind === "smart-edit" && selectedTargetAnchor && !smartEditNodeId ? (
<SmartEditNodeComposer
key={selectedNode.id}
anchor={selectedTargetAnchor}
draft={(selectedNode.draftValues?.smartEditDraft || {}) as Record<string, any>}
sources={selectedTargetSourceNodes.filter(node => node.inputType === "video" && getNodeReferenceMediaUrl(node)).map(node => ({id:node.id,name:node.title,url:getNodeReferenceMediaUrl(node),duration:getVideoNodeDurationSeconds(node)}))}
availableSources={nodes.filter(node => node.id !== selectedNode.id && node.inputType === "video" && getNodeReferenceMediaUrl(node)).map(node => ({id:node.id,name:node.title,url:getNodeReferenceMediaUrl(node),duration:getVideoNodeDurationSeconds(node)}))}
busy={generationTasks.tasks.some(task => task.request.node.id === selectedNode.id && (task.status === "pending" || task.status === "running"))}
onDraft={patch => setNodes(current => current.map(node => node.id === selectedNode.id ? {...node,draftValues:{...node.draftValues,smartEditDraft:{...(node.draftValues?.smartEditDraft as Record<string,unknown> || {}),...patch}}} : node))}
onAddSource={source => setEdges(current => current.some(edge => edge.source === source && edge.target === selectedNode.id) ? current : [...current,{id:makeId("edge"),source,target:selectedNode.id}])}
onRemoveSource={source => {
setEdges(current => current.filter(edge => !(edge.source === source && edge.target === selectedNode.id)));
setNodes(current => current.map(node => node.id === selectedNode.id ? {...node,draftValues:{...node.draftValues,smartEditDraft:{...(node.draftValues?.smartEditDraft as Record<string,any> || {}),assets:((node.draftValues?.smartEditDraft as Record<string,any>)?.assets || []).filter((asset:{id:string}) => asset.id !== source)}}} : node));
}}
onReference={() => setLineConnectState({sourceId:null,targetId:selectedNode.id,cursorCanvasX:selectedNode.x-80,cursorCanvasY:selectedNode.y+selectedNode.height/2})}
onUpload={files => addUploadedFilesAtPoint(files,{x:selectedNode.x-600,y:selectedNode.y},selectedNode.id)}
onSubmit={async () => {
const draft = (selectedNode.draftValues?.smartEditDraft || {}) as Record<string,any>;
const assets = selectedTargetSourceNodes.filter(node => node.inputType === "video" && getNodeReferenceMediaUrl(node)).map(node => ({id:node.id,name:node.title,url:getNodeReferenceMediaUrl(node),subtitles:[]}));
const params = smartEditRequestSchema.parse({ratio:"16:9",resolution:"1080p",targetDuration:20,...draft,mode:["auto","montage","talk","explain","ads"].includes(draft.mode)?draft.mode:"auto",semantic:true,operation:"plan",assets,preferredModelRef:videoCanvasFunctionConfig.defaultMultimodalModel});
const requestId = await generationTasks.submit({...selectedNode,draftValues:{...selectedNode.draftValues,action:"SMART_EDIT_WORKFLOW",nodeType:"smart-edit"}},"smart-edit",params,edges.filter(edge => edge.target === selectedNode.id));
setNodes(current => current.map(node => node.id === selectedNode.id ? {...node,draftValues:{...node.draftValues,smartEditDraft:{...draft,assets,preparationRequestId:requestId}}} : node));
setSmartEditNodeId(selectedNode.id);
return requestId;
}}
/>
) : null}
{selectedNode && selectedTargetModuleKind === "text" && selectedTargetAnchor ? (
<TextTargetModuleComposer
anchor={selectedTargetAnchor}
@ -10981,16 +11023,17 @@ export function LiblibVideoCanvasReplica({
plans={smartEditNode.draftValues?.smartEditPlans as EditPlan[]|undefined}
planTaskId={getDraftString(smartEditNode,"smartEditPlanTaskId")}
preferredModelRef={videoCanvasFunctionConfig.defaultMultimodalModel}
connectedSourceIds={edges.filter(edge=>edge.target===smartEditNode.id).map(edge=>edge.source)}
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))}
onPlan={params=>generationTasks.submit({...smartEditNode,draftValues:{...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}]);
return generationTasks.submit(result,"smart-edit",{operation:"render",plan,sourceNodeId:smartEditNode.id},[{id:makeId("edge"),source:smartEditNode.id,target:result.id}]);
}}
/>:null}
{directorConsoleNode ? (
@ -18298,6 +18341,7 @@ function CloneCanvasNode({
onUpdateLocalEditRegions,
onTextResourceChange,
selected,
smartEditPreviewUrl,
}: {
connectionsHidden: boolean;
connectionHoverActive: boolean;
@ -18305,6 +18349,7 @@ function CloneCanvasNode({
onClearGridSplitSelection: () => void;
onClearLocalEditRegions: () => void;
node: CloneNode;
smartEditPreviewUrl?: string;
onCancelGeneration: () => void;
onContextMenu: (event: ReactMouseEvent<HTMLDivElement>) => void;
onExtractVideoFrame: (kind: VideoFrameExtractionKind,time?:number) => void;
@ -18360,7 +18405,7 @@ function CloneCanvasNode({
if (targetModuleKind) {
return (
<CanvasNodeShell {...shellProps}>
<TargetModuleCanvasNode kind={targetModuleKind} node={node} onAction={onModuleAction} selected={selected} />
<TargetModuleCanvasNode previewUrl={smartEditPreviewUrl} kind={targetModuleKind} node={node} onAction={onModuleAction} selected={selected} />
</CanvasNodeShell>
);
}
@ -18598,12 +18643,15 @@ function TargetModuleCanvasNode({
node,
onAction,
selected,
previewUrl,
}: {
kind: AddModuleKind;
previewUrl?: string;
node: CloneNode;
onAction: (actionLabel: TargetModuleActionLabel) => void;
selected: boolean;
}) {
if (kind === "smart-edit") return <SmartEditNodeCard selected={selected} title={node.title} previewUrl={previewUrl} onOpen={() => onAction("打开智能剪辑")} />;
const HeaderIcon = kind === "video-clip" ? Scissors : kind === "director" ? Settings2 : kind === "audio" ? Music2 : kind === "video" ? Video : kind === "image" ? ImageIcon : FileText;
const actions: Record<AddModuleKind, Array<{ icon: LucideIcon | "hd"; label: TargetModuleActionLabel }>> = {
"smart-edit": [{icon:Scissors,label:"打开智能剪辑"}],
@ -18639,7 +18687,6 @@ function TargetModuleCanvasNode({
"video-clip": [],
};
const isCompactTargetModule =
kind === "smart-edit" ||
kind === "text" ||
kind === "image" ||
kind === "video" ||
@ -18686,7 +18733,7 @@ function TargetModuleCanvasNode({
<TargetModuleHeroGraphic kind={kind} />
</div>
)}
{kind === "smart-edit" ? <div className="absolute inset-x-0 top-[200px] flex flex-col items-center gap-3 px-5 text-center"><p className="text-sm text-white/50">自动选片 · 音乐卡点 · 字幕 · 多素材时间轴</p><button type="button" className="rounded bg-white/10 px-3 py-2 text-sm" onPointerDown={event=>event.stopPropagation()} onClick={()=>onAction("打开智能剪辑")}>打开智能剪辑</button></div> : kind === "video-clip" ? (
{kind === "video-clip" ? (
<div className="absolute inset-x-0 top-[254px] text-center text-[16px] text-white/34">
空空如也,请连接视频节点后操作
</div>

View File

@ -1,7 +1,9 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import {
compiledSubtitles,
defaultCaptionStyle,
type CaptionStyle,
planTimeline,
SECOND,
type EditPlan,
@ -13,7 +15,9 @@ export function EditPreview({
onTime,
onPlaying,
onError,
fit = false,
}: {
fit?: boolean;
plan: EditPlan;
time: number;
playing: boolean;
@ -25,8 +29,20 @@ export function EditPreview({
music = useRef<HTMLAudioElement | null>(null),
latest = useRef({ time, plan, onTime, onPlaying });
latest.current = { time, plan, onTime, onPlaying };
const frameRef = useRef<HTMLDivElement>(null);
const [frameSize, setFrameSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = frameRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) =>
setFrameSize({ width: entry.contentRect.width, height: entry.contentRect.height }),
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const timeline = planTimeline(plan),
current = timeline.clips.filter((c) => time * SECOND >= c.start && time * SECOND < c.end),
visibleTime = Math.min(time * SECOND, timeline.duration - 1),
current = timeline.clips.filter((c) => visibleTime >= c.start && visibleTime < c.end),
subs = compiledSubtitles(plan).filter((s) => time * SECOND >= s.start && time * SECOND < s.end);
useEffect(() => {
if (!playing) return;
@ -103,46 +119,112 @@ export function EditPreview({
else audio.pause();
}
}, [time, playing, plan]);
const [ratioWidth, ratioHeight] = plan.ratio.split(":").map(Number);
const ratio = ratioWidth / ratioHeight;
const height = Math.min(frameSize.height || 720, (frameSize.width || 1280) / ratio);
const scale = height / 720;
const incomingClip = current.find((c) => c.overlap && visibleTime < c.start + c.overlap);
const progress = incomingClip ? (visibleTime - incomingClip.start) / incomingClip.overlap : 1;
return (
<div
className="relative mx-auto w-full max-w-4xl overflow-hidden bg-black"
style={{ aspectRatio: plan.ratio.replace(":", "/") }}
ref={frameRef}
className={
fit
? "relative h-full w-full overflow-hidden"
: "relative mx-auto w-full max-w-4xl overflow-hidden bg-black"
}
style={fit ? undefined : { aspectRatio: plan.ratio.replace(":", "/") }}
aria-label="剪辑实时预览"
>
{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 (
<video
key={clip.id}
ref={(element) => {
if (element) videos.current.set(clip.id, element);
else videos.current.delete(clip.id);
}}
src={asset.url}
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full object-contain"
<div
style={
fit
? {
position: "absolute",
width: height * ratio,
height,
left: "50%",
top: "50%",
transform: "translate(-50%,-50%)",
overflow: "hidden",
background: "black",
}
: { position: "absolute", inset: 0 }
}
>
{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 && visibleTime < clip.start + clip.overlap;
return (
<video
key={clip.id}
ref={(element) => {
if (element) videos.current.set(clip.id, element);
else videos.current.delete(clip.id);
}}
src={asset.url}
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full object-contain"
style={{
visibility: active ? "visible" : "hidden",
opacity: incoming
? clip.transitionType === "fade"
? progress
: progress < 0.5
? 0
: 1
: 1,
}}
onLoadedMetadata={(e) => {
const target =
clip.sourceIn / SECOND +
(Math.max(0, visibleTime - clip.start) / SECOND) * clip.speed;
e.currentTarget.currentTime = Math.min(clip.sourceOut / SECOND - 0.001, target);
}}
onError={() => {
onPlaying(false);
onError(`${asset.name} 预览加载失败`);
}}
/>
);
})}
{plan.music ? <audio ref={music} src={plan.music.url} preload="metadata" /> : null}
{incomingClip && incomingClip.transitionType !== "fade" && (
<div
style={{
visibility: active ? "visible" : "hidden",
opacity: incoming ? Math.max(0, (time * SECOND - clip.start) / clip.overlap) : 1,
}}
onError={() => {
onPlaying(false);
onError(`${asset.name} 预览加载失败`);
position: "absolute",
inset: 0,
pointerEvents: "none",
background: incomingClip.transitionType === "fadeblack" ? "black" : "white",
opacity: 1 - Math.abs(2 * progress - 1),
}}
/>
);
})}
{plan.music ? <audio ref={music} src={plan.music.url} preload="metadata" /> : null}
<div
className="pointer-events-none absolute inset-x-4 bottom-[5%] text-center text-[clamp(12px,2vw,22px)] text-white"
style={{ textShadow: "-1px -1px 2px black,1px 1px 2px black" }}
>
{subs.map((s, i) => (
<div key={i}>{s.text}</div>
))}
)}
<div className="pointer-events-none absolute inset-x-4 bottom-[5%] text-center">
{subs.map((s, i) => {
const style = (s as { style?: CaptionStyle }).style || defaultCaptionStyle;
return (
<div
key={i}
style={{
fontFamily: '"Noto Sans CJK SC", "Source Han Sans CN", sans-serif',
whiteSpace: "pre-wrap",
fontSize: style.fontSize * scale,
letterSpacing: style.spacing * scale,
color: style.color,
opacity: style.opacity,
WebkitTextStroke: `${style.outlineWidth * scale * 2}px ${style.outlineColor}`,
paintOrder: "stroke fill",
lineHeight: 1.2,
}}
>
{s.text}
</div>
);
})}
</div>
</div>
</div>
);

View File

@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { X, Upload } from "lucide-react";
import { SmartEditEditor, SmartEditEmptyEditor } from "./SmartEditEditor";
import { EditPreview } from "./EditPreview";
import {
smartEditRequestSchema,
@ -19,6 +20,7 @@ export function SmartEditDialog({
plans: serverPlans,
planTaskId,
sources,
connectedSourceIds = [],
audios,
preferredModelRef,
tasks,
@ -31,6 +33,7 @@ export function SmartEditDialog({
plans?: EditPlan[];
planTaskId?: string;
sources: Asset[];
connectedSourceIds?: string[];
audios: { url: string; name: string }[];
preferredModelRef?: string;
tasks: CanvasGenerationTask[];
@ -41,11 +44,11 @@ export function SmartEditDialog({
}) {
const [draft, setDraft] = useState<Record<string, any>>(() => ({
assets: [],
mode: "montage",
mode: "auto",
prompt: "",
targetDuration: 20,
ratio: "16:9",
resolution: "720p",
resolution: "1080p",
semantic: true,
removeSilence: false,
transcribe: false,
@ -75,6 +78,7 @@ export function SmartEditDialog({
[cap, setCap] = useState<any>(),
[zoom, setZoom] = useState(1),
[snap, setSnap] = useState(true);
const [configure, setConfigure] = useState(false);
const applied = useRef(planTaskId);
useEffect(() => {
if (planTaskId && planTaskId !== applied.current) {
@ -83,6 +87,7 @@ export function SmartEditDialog({
setSelected(0);
setTime(0);
setPlaying(false);
setConfigure(false);
}
}, [planTaskId, serverPlans]);
useEffect(() => {
@ -108,6 +113,45 @@ export function SmartEditDialog({
} 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({ plans: draft.plans.map((p: EditPlan, i: number) => (i === selected ? next : p)) });
@ -173,6 +217,36 @@ export function SmartEditDialog({
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 ? "读取已连接的视频素材" : "连接视频素材后开始剪辑")
}
/>
);
if (plan && timeline && !configure)
return (
<SmartEditEditor
key={`${planTaskId || "draft"}:${selected}`}
plan={plan}
onChange={changePlan}
onClose={onClose}
onConfigure={() => setConfigure(true)}
onRender={onRender}
tasks={tasks}
canRender={Boolean(cap?.capabilities?.render)}
exportState={draft.exportState}
onExportState={(exportState) => patch({ exportState })}
/>
);
return (
<div
role="dialog"
@ -182,6 +256,11 @@ export function SmartEditDialog({
>
<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>
@ -197,6 +276,8 @@ export function SmartEditDialog({
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>
@ -204,14 +285,15 @@ export function SmartEditDialog({
</select>
</label>
<div>
<div className="mb-2 text-sm">素材({draft.assets.length}/6)</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 < 6) patch({ assets: [...draft.assets, asset] });
if (asset && draft.assets.length < 12)
patch({ assets: [...draft.assets, asset] });
}}
>
<option value="">选择画布已有视频</option>
@ -234,8 +316,8 @@ export function SmartEditDialog({
onChange={async (e) => {
const files = Array.from(e.target.files || []);
e.target.value = "";
if (files.length + draft.assets.length > 6) {
setError("最多 6 个素材");
if (files.length + draft.assets.length > 12) {
setError("最多 12 个素材");
return;
}
setBusy(true);
@ -308,7 +390,7 @@ export function SmartEditDialog({
className={`${input} w-full`}
type="number"
min={3}
max={90}
max={300}
value={draft.targetDuration}
onChange={(e) => patch({ targetDuration: Number(e.target.value) })}
/>
@ -321,7 +403,7 @@ export function SmartEditDialog({
value={draft.ratio}
onChange={(e) => patch({ ratio: e.target.value })}
>
{["16:9", "9:16", "1:1"].map((v) => (
{["21:9", "16:9", "9:16", "1:1", "4:3", "3:4"].map((v) => (
<option key={v}>{v}</option>
))}
</select>
@ -470,7 +552,9 @@ export function SmartEditDialog({
</select>
) : null}
{cap &&
(!cap.capabilities.scenes || !cap.capabilities.ffmpeg || !cap.capabilities.ffprobe) ? (
((draft.mode !== "sequence" && !cap.capabilities.scenes) ||
!cap.capabilities.ffmpeg ||
!cap.capabilities.ffprobe) ? (
<p role="alert" className="text-sm text-amber-200">
剪辑处理环境尚未准备好,请配置本地分析服务。
</p>
@ -480,7 +564,7 @@ export function SmartEditDialog({
className={`${button} w-full bg-white/10`}
disabled={
!draft.assets.length ||
!cap?.capabilities.scenes ||
(draft.mode !== "sequence" && !cap?.capabilities.scenes) ||
!cap?.capabilities.ffmpeg ||
!cap?.capabilities.ffprobe
}

View File

@ -0,0 +1,912 @@
.root {
position: fixed;
inset: 0;
z-index: 160;
display: flex;
flex-direction: column;
padding: 0 8px 8px;
background: #121212;
color: #eee;
font:
13px / 1.5 -apple-system,
BlinkMacSystemFont,
"PingFang SC",
Inter,
"Noto Sans SC",
"Microsoft YaHei",
sans-serif;
box-sizing: border-box;
}
.root * {
box-sizing: border-box;
}
.root button,
.root input,
.root textarea,
.root select {
font: inherit;
}
.root button {
cursor: pointer;
border: 0;
color: inherit;
background: transparent;
}
.root button:disabled {
opacity: 0.3;
cursor: default;
}
.root button:focus-visible,
.root input:focus-visible,
.root textarea:focus-visible {
outline: 1px solid #aaa;
outline-offset: 2px;
}
.root :global(.se-header) {
height: 48px;
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4px;
}
.root :global(.se-title) {
font-size: 13px;
font-weight: 500;
}
.root :global(.se-actions) {
display: flex;
gap: 4px;
align-items: center;
position: relative;
}
.root :global(.se-icon) {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
border-radius: 8px;
color: #c9c9c9;
}
.root :global(.se-icon:hover) {
background: #ffffff12;
}
.root :global(.se-icon.active) {
background: #ffffff26;
color: white;
}
.root :global(.se-icon svg) {
width: 14px;
height: 14px;
stroke-width: 1.65;
}
.root :global(.se-exit svg) {
width: 22px;
height: 22px;
}
.root :global(.se-export) {
height: 32px;
min-width: 68px;
padding: 0 12px;
border-radius: 8px;
background: #fff;
color: #171717;
display: flex;
gap: 7px;
align-items: center;
justify-content: center;
}
.root :global(.se-export svg) {
width: 11px;
height: 11px;
}
.root :global(.se-menu) {
position: absolute;
right: 36px;
top: 41px;
z-index: 40;
width: 200px;
padding: 8px;
border-radius: 16px;
background: #262626;
box-shadow: 0 8px 24px #0004;
}
.root :global(.se-menu-title) {
font-size: 12px;
color: #929292;
padding: 0 4px 6px;
}
.root :global(.se-menu button) {
width: 100%;
height: 40px;
text-align: left;
display: flex;
gap: 9px;
align-items: center;
padding: 0 6px;
border-radius: 8px;
}
.root :global(.se-menu button:hover) {
background: #ffffff0d;
}
.root :global(.se-menu svg) {
width: 15px;
height: 15px;
}
.root :global(.se-body) {
min-height: 120px;
flex: 1;
display: flex;
gap: 8px;
padding-top: 1px;
overflow: hidden;
}
.root :global(.se-preview) {
position: relative;
min-width: 0;
flex: 1;
background: #1f1f1f;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.root :global(.se-preview-stage) {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
}
.root :global(.se-panel) {
width: 300px;
flex: none;
border: 1px solid #373737;
border-radius: 12px;
background: #1f1f1f;
display: flex;
flex-direction: column;
overflow: hidden;
}
.root :global(.se-panel.left) {
width: 372px;
}
.root :global(.se-panel-head) {
height: 44px;
min-height: 44px;
border-bottom: 1px solid #373737;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
font-size: 14px;
}
.root :global(.se-panel-head .se-icon) {
width: 28px;
height: 28px;
}
.root :global(.se-panel-head .se-icon svg) {
width: 24px;
height: 24px;
}
.root :global(.se-panel-scroll) {
flex: 1;
overflow: auto;
padding: 16px;
scrollbar-width: thin;
scrollbar-color: #505050 transparent;
}
.root :global(.se-panel h3) {
font-size: 16px;
font-weight: 500;
margin: 0 0 20px;
}
.root :global(.se-field) {
display: flex;
align-items: center;
height: 44px;
border-radius: 8px;
overflow: hidden;
background: #292929;
margin: 0 0 12px;
font-size: 16px;
}
.root :global(.se-field label) {
width: 50%;
padding: 0 12px;
background: #373737;
align-self: stretch;
display: flex;
align-items: center;
color: #aaa;
}
.root :global(.se-field input) {
width: 50%;
min-width: 0;
border: 0;
color: #eee;
background: transparent;
text-align: right;
height: 44px;
padding: 0 12px;
outline: none;
}
.root :global(.se-divider) {
height: 1px;
background: #373737;
margin: 20px 0;
}
.root :global(.se-muted) {
font-size: 13px;
color: #909090;
}
.root :global(.se-small-label) {
font-size: 12px;
color: #8e8e8e;
margin: 0 0 12px;
}
.root :global(.se-tabs) {
display: flex;
align-items: center;
gap: 16px;
height: 44px;
padding: 0 16px;
border-bottom: 1px solid #373737;
}
.root :global(.se-tabs button) {
color: #8a8a8a;
padding: 0;
}
.root :global(.se-tabs .active) {
color: white;
}
.root :global(.se-search) {
display: flex;
align-items: center;
background: #303030;
border-radius: 8px;
height: 32px;
padding: 0 12px;
margin-bottom: 8px;
color: #aaa;
}
.root :global(.se-search input) {
background: none;
border: 0;
width: 100%;
min-width: 0;
outline: 0;
color: #eee;
font-size: 13px;
}
.root :global(.se-wide-button) {
height: 32px;
width: 100%;
border-radius: 8px;
background: #353535;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 13px;
}
.root :global(.se-empty) {
margin-top: 28px;
color: #929292;
}
.root :global(.se-caption-item) {
border: 1px solid #3b3b3b;
border-radius: 8px;
padding: 10px;
margin-top: 10px;
cursor: pointer;
}
.root :global(.se-caption-item.active) {
border-color: #eee;
}
.root :global(.se-caption-item p) {
margin: 6px 0 0;
white-space: pre-wrap;
}
.root :global(.se-caption-times) {
display: flex;
align-items: center;
gap: 6px;
color: #999;
}
.root :global(.se-caption-times input) {
width: 85px;
border: 0;
color: #aaa;
background: transparent;
}
.root :global(.se-textarea) {
width: 100%;
min-height: 92px;
background: #171717;
color: #eee;
border: 0;
border-radius: 8px;
padding: 12px;
resize: vertical;
margin-bottom: 16px;
}
.root :global(.se-style-row) {
margin-bottom: 16px;
}
.root :global(.se-style-row > label) {
display: block;
color: #999;
font-size: 12px;
margin-bottom: 8px;
}
.root :global(.se-style-controls) {
display: flex;
gap: 8px;
align-items: center;
}
.root :global(.se-style-controls input[type="range"]) {
flex: 1;
min-width: 0;
accent-color: #fff;
}
.root :global(.se-style-controls input[type="number"]) {
width: 86px;
background: #333;
border: 0;
border-radius: 7px;
color: white;
height: 32px;
padding: 0 10px;
}
.root :global(.se-style-controls input[type="text"]) {
min-width: 0;
width: 100%;
background: #333;
border: 0;
border-radius: 7px;
color: white;
height: 32px;
padding: 0 10px;
}
.root :global(.se-color) {
height: 28px;
width: 32px;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
flex: none;
}
.root :global(.se-font) {
width: 100%;
height: 32px;
background: #333;
color: white;
border: 0;
border-radius: 7px;
padding: 0 10px;
}
.root :global(.se-presets) {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 5px;
}
.root :global(.se-presets button) {
height: 32px;
border: 1px solid #444;
border-radius: 6px;
background: #292929;
font-size: 21px;
}
.root :global(.se-panel-foot) {
padding: 0 16px 8px;
display: flex;
justify-content: flex-end;
}
.root :global(.se-panel-foot button) {
height: 32px;
border-radius: 8px;
background: #333;
padding: 0 12px;
font-size: 12px;
}
.root :global(.se-transition-list) {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
padding: 6px 4px;
}
.root :global(.se-transition-card) {
min-width: 0;
text-align: left;
padding: 0;
color: #aaa;
font-size: 12px;
}
.root :global(.se-transition-card.selected) {
color: white;
}
.root :global(.se-transition-demo) {
display: block;
height: 100px;
position: relative;
overflow: hidden;
border: 1px solid #303030;
border-radius: 8px;
background: #171717;
margin-bottom: 4px;
}
.root :global(.se-transition-demo video) {
width: 100%;
height: 100%;
object-fit: cover;
}
.root :global(.se-transition-card.selected .se-transition-demo) {
border-color: #eee;
}
.root :global(.se-transition-demo:after) {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(120deg, transparent, #ddd8);
}
.root :global(.se-transition-demo.black:after) {
background: linear-gradient(120deg, transparent, #000e);
}
.root :global(.se-transition-demo.white:after) {
background: linear-gradient(120deg, transparent, #fffe);
}
.root :global(.se-timeline) {
position: relative;
height: 244px;
min-height: 130px;
flex: none;
margin-top: 8px;
border: 1px solid #393939;
border-radius: 12px;
background: #1c1c1c;
overflow: hidden;
}
.root :global(.se-resizer) {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 5px;
cursor: row-resize;
z-index: 8;
}
.root :global(.se-toolbar) {
position: relative;
height: 48px;
padding: 0 16px;
border-bottom: 1px solid #333;
display: flex;
align-items: center;
justify-content: space-between;
}
.root :global(.se-toolbar-group) {
display: flex;
gap: 8px;
align-items: center;
}
.root :global(.se-toolbar-middle) {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.root :global(.se-play) {
border-radius: 50%;
background: #ffffff26;
}
.root :global(.se-current-time) {
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.root :global(.se-duration) {
font-size: 13px;
color: #999;
font-variant-numeric: tabular-nums;
}
.root :global(.se-zoom) {
width: 56px;
height: 2px;
accent-color: white;
cursor: pointer;
margin: 0;
}
.root :global(.se-tracks) {
position: relative;
display: flex;
height: calc(100% - 48px);
}
.root :global(.se-track-labels) {
width: 94px;
flex: none;
padding-top: 30px;
position: relative;
z-index: 4;
background: #1c1c1c;
}
.root :global(.se-track-label) {
height: 64px;
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
padding-right: 14px;
}
.root :global(.se-track-label.caption) {
height: 28px;
}
.root :global(.se-track-label svg) {
color: #919191;
}
.root :global(.se-track-label .se-icon) {
width: 24px;
height: 24px;
border-radius: 4px;
}
.root :global(.se-scroll) {
overflow: auto;
position: relative;
flex: 1;
min-width: 0;
scrollbar-width: thin;
scrollbar-color: #444 transparent;
}
.root :global(.se-track-content) {
position: relative;
min-height: 100%;
padding-right: 20px;
}
.root :global(.se-ruler) {
height: 30px;
position: relative;
cursor: crosshair;
user-select: none;
}
.root :global(.se-tick) {
position: absolute;
top: 0;
width: 1px;
height: 8px;
background: #6665;
}
.root :global(.se-tick.major) {
height: 12px;
background: #aaa7;
}
.root :global(.se-tick span) {
position: absolute;
top: 8px;
left: 4px;
white-space: nowrap;
color: #777;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.root :global(.se-video-track) {
height: 64px;
position: relative;
margin-bottom: 8px;
}
.root :global(.se-clip) {
position: absolute;
top: 0;
height: 64px;
overflow: hidden;
border-radius: 8px 8px 0 0;
border: 1px solid #1d1d1d;
padding: 0;
text-align: left;
background: #3c3c3c;
user-select: none;
}
.root :global(.se-clip.selected) {
border: 2px solid white;
border-radius: 8px;
z-index: 3;
}
.root :global(.se-filmstrip) {
height: 49px;
display: flex;
overflow: hidden;
}
.root :global(.se-filmstrip video) {
width: 88px;
flex: none;
height: 49px;
object-fit: cover;
pointer-events: none;
}
.root :global(.se-clip-name) {
position: absolute;
top: 0;
left: 0;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
padding: 2px 4px;
font-size: 11px;
color: white;
background: #0008;
pointer-events: none;
}
.root :global(.se-wave) {
height: 14px;
background: #155864c2;
}
.root :global(.se-wave.muted) {
background: #3c3c3c;
}
.root :global(.se-handle) {
position: absolute;
top: 0;
bottom: 0;
width: 7px;
cursor: ew-resize;
z-index: 5;
background: #ffffff12;
}
.root :global(.se-handle.left) {
left: 0;
}
.root :global(.se-handle.right) {
right: 0;
}
.root :global(.se-junction) {
position: absolute;
top: 18px;
width: 24px;
height: 28px;
background: #111c;
border: 1px solid #8888;
border-radius: 5px;
z-index: 6;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
}
.root :global(.se-junction svg) {
width: 14px;
height: 14px;
}
.root :global(.se-caption-track) {
height: 28px;
position: relative;
background: #242424;
margin-bottom: 0;
}
.root :global(.se-caption-block) {
position: absolute;
top: 0;
height: 26px;
background: #653e38;
border-radius: 5px;
padding: 2px 8px;
overflow: hidden;
white-space: nowrap;
text-align: left;
font-size: 11px;
border: 1px solid #87594b;
color: #b98f81;
user-select: none;
}
.root :global(.se-caption-block.selected) {
border-color: #eee;
}
.root :global(.se-playhead) {
position: absolute;
top: 3px;
bottom: 16px;
width: 1px;
background: #fff;
z-index: 9;
pointer-events: none;
}
.root :global(.se-playhead:before) {
content: "";
position: absolute;
top: -1px;
left: -4px;
width: 9px;
height: 12px;
border-radius: 3px 3px 5px 5px;
background: #eee;
}
.root :global(.se-error) {
position: absolute;
left: 50%;
top: 54px;
transform: translateX(-50%);
z-index: 50;
border: 1px solid #694c36;
border-radius: 8px;
background: #35281f;
color: #f5ce9b;
padding: 8px 16px;
max-width: 70%;
}
.root :global(.se-context) {
position: fixed;
z-index: 100;
background: #292929;
border: 1px solid #444;
border-radius: 10px;
padding: 5px;
min-width: 150px;
box-shadow: 0 8px 24px #0008;
}
.root :global(.se-context button) {
display: block;
width: 100%;
height: 32px;
padding: 0 10px;
text-align: left;
border-radius: 5px;
}
.root :global(.se-context button:hover) {
background: #ffffff12;
}
.root :global(.se-progress) {
position: absolute;
inset: 0;
z-index: 15;
display: flex;
align-items: center;
justify-content: center;
background: #181818bb;
color: #ddd;
}
.root :global(.se-export-result) {
position: absolute;
top: 48px;
right: 16px;
z-index: 30;
padding: 12px;
border-radius: 10px;
background: #292929;
box-shadow: 0 8px 24px #0006;
}
.root :global(.se-export-result a) {
color: #fff;
text-decoration: underline;
}
.root :global(.se-export-result button) {
margin-left: 14px;
}
.root :global(.se-setup-back) {
padding: 0 8px;
color: #aaa;
font-size: 12px;
}
.root :global(.se-value) {
width: 50%;
display: flex;
align-items: center;
padding-right: 12px;
gap: 3px;
}
.root :global(.se-value input) {
width: 100%;
padding-right: 0;
}
.root :global(.se-value span) {
flex: none;
}
.root :global(.se-preview:fullscreen) {
border-radius: 0;
background: #000;
}
.root :global(.se-toolbar) {
padding-bottom: 1px;
}
.root :global(.se-track-type) {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.root :global(.se-track-type svg) {
width: 14px;
height: 14px;
}
.root :global(.se-zoom) {
appearance: none;
background: #777;
border-radius: 2px;
}
.root :global(.se-zoom::-webkit-slider-thumb) {
appearance: none;
width: 12px;
height: 12px;
background: #eee;
border-radius: 50%;
border: 0;
}
.root :global(.se-zoom::-moz-range-thumb) {
width: 12px;
height: 12px;
background: #eee;
border-radius: 50%;
border: 0;
}
@media (max-width: 1350px) {
.root :global(.se-history) {
display: none;
}
}
.root :global(.se-empty-start) {
text-align: center;
color: #888;
font-size: 13px;
}
.root :global(.se-empty-start p) {
margin: 0 0 20px;
}
.root :global(.se-empty-start .se-wide-button) {
width: 140px;
margin: 8px auto;
}
.root :global(.se-value input) {
appearance: textfield;
}
.root :global(.se-value input::-webkit-inner-spin-button),
.root :global(.se-value input::-webkit-outer-spin-button) {
appearance: none;
margin: 0;
}
.root :global(.se-number-steps) {
display: flex;
flex-direction: column;
width: 10px;
flex: none;
line-height: 8px;
}
.root :global(.se-number-steps button) {
padding: 0;
height: 8px;
font-size: 11px;
color: #888;
line-height: 8px;
}
.root :global(.se-number-steps button:hover) {
color: white;
}
.root :global(.se-current-time),
.root :global(.se-duration) {
width: 40px;
text-align: center;
line-height: 1.55;
}
.root :global(.se-panel) {
position: relative;
}
.root :global(.se-column-resizer) {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 5px;
z-index: 12;
cursor: col-resize;
}
.root :global(.se-column-resizer.right) {
left: auto;
right: 0;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,377 @@
.card {
position: relative;
width: 100%;
height: 100%;
padding: 8px;
border: 1px solid #454545;
border-radius: 18px;
background: #262626;
color: #eee;
font-family: Arial, "PingFang SC", sans-serif;
}
.title {
position: absolute;
top: -28px;
left: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #929292;
white-space: nowrap;
}
.preview {
position: relative;
height: calc(100% - 80px);
overflow: hidden;
border-radius: 10px 10px 0 0;
background: #202020;
}
.preview > video {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.35;
}
.open {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.open button {
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: white;
color: #181818;
font-size: 16px;
cursor: pointer;
}
.ruler {
height: 26px;
display: flex;
justify-content: space-around;
align-items: center;
border-bottom: 1px solid #393939;
font-size: 8px;
color: #929292;
}
.tracks {
padding: 8px;
}
.tracks > div {
display: flex;
gap: 3px;
height: 20px;
margin-bottom: 3px;
}
.tracks i {
flex: 1;
background: #333;
border-radius: 3px;
}
.composer {
position: absolute;
z-index: 30;
transform: translateX(-50%);
width: 660px;
max-width: calc(100vw - 32px);
padding: 8px;
border: 0.5px solid #333;
border-radius: 16px;
background: #262626;
color: #eee;
box-shadow: 0 4px 16px #0002;
font:
14px / 1.5 Arial,
"PingFang SC",
sans-serif;
display: flex;
flex-direction: column;
gap: 8px;
}
.composer button {
cursor: pointer;
border: 0;
color: inherit;
font: inherit;
background: none;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.composer button:disabled {
opacity: 0.4;
cursor: default;
}
.referenceRow {
height: 30px;
display: flex;
justify-content: space-between;
padding: 4px 4px 0;
}
.referenceRow > button:first-child {
font-size: 12px;
border-radius: 16px;
background: #333;
color: #999;
padding: 0 6px;
height: 26px;
}
.referenceRow > button:last-child {
color: #999;
width: 24px;
height: 24px;
}
.sources {
display: flex;
gap: 8px;
min-height: 54.8px;
flex-wrap: wrap;
padding: 0 4px;
}
.source {
width: 48px;
height: 48px;
position: relative;
border-radius: 8px;
background: #191919;
}
.source video {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.source b {
position: absolute;
top: 3px;
left: 3px;
font-size: 9px;
font-weight: 400;
background: #0009;
border-radius: 8px;
padding: 0 3px;
}
.source > svg {
position: absolute;
top: 16px;
left: 16px;
width: 16px;
height: 16px;
padding: 3px;
border-radius: 50%;
background: #0009;
}
.source > span {
position: absolute;
bottom: 2px;
left: 3px;
font-size: 8px;
background: #0009;
line-height: 11px;
}
.source > button {
display: none;
position: absolute;
top: -4px;
right: -4px;
width: 15px;
height: 15px;
border-radius: 50%;
background: #666;
}
.source:hover > button,
.source:focus-within > button {
display: flex;
}
.composer textarea {
display: block;
width: 100%;
resize: none;
border: 0;
outline: 0;
background: transparent;
color: #eee;
padding: 8px 4px;
font:
14px / 1.8 Arial,
"PingFang SC",
sans-serif;
}
.composer textarea::placeholder {
color: #777;
}
.controls {
height: 32px;
display: flex;
gap: 8px;
}
.controls .mode,
.controls .parameters {
font-size: 12px;
height: 32px;
padding: 0 8px;
border-radius: 8px;
background: #333;
}
.controls .mode {
background: none;
}
.controls .send {
margin-left: auto;
width: 32px;
height: 32px;
border-radius: 12px;
background: white;
color: #222;
}
.popup {
position: absolute;
z-index: 5;
bottom: 40px;
padding: 12px;
background: #262626;
border: 0.5px solid #393939;
border-radius: 16px;
box-shadow: 0 4px 16px #0006;
color: #929292;
}
.settings {
left: 112px;
width: 340px;
}
.settings label {
display: block;
font-size: 12px;
margin: 0 0 8px;
}
.ratios {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-bottom: 8px;
}
.ratios button {
height: 62px;
flex-direction: column;
gap: 8px;
font-size: 12px;
border: 1px solid #393939;
border-radius: 8px;
}
.ratios i {
display: block;
border: 1.5px solid currentColor;
border-radius: 1px;
}
.settings button[aria-pressed="true"] {
color: white;
background: #414141;
border-color: #999;
}
.duration {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
height: 22px;
}
.duration input[type="range"] {
min-width: 0;
flex: 1;
accent-color: white;
height: 4px;
}
.duration input[type="number"] {
width: 48px;
height: 22px;
appearance: textfield;
background: #333;
border: 0;
outline: 0;
border-radius: 6px;
color: #eee;
text-align: center;
font-size: 12px;
}
.duration input::-webkit-inner-spin-button {
appearance: none;
}
.duration > span {
font-size: 12px;
}
.resolutions {
display: flex;
gap: 8px;
}
.resolutions button {
flex: 1;
height: 34px;
border: 1px solid #393939;
border-radius: 10px;
font-size: 12px;
}
.modeMenu {
left: 8px;
width: 160px;
padding: 6px;
}
.modeMenu button {
width: 100%;
height: 36px;
padding: 0 8px;
justify-content: space-between;
color: #ddd;
font-size: 13px;
border-radius: 8px;
}
.modeMenu button:hover,
.sourceMenu button:hover {
background: #383838;
}
.sourceMenu {
left: 8px;
bottom: calc(100% - 40px);
width: 260px;
max-height: 320px;
overflow: auto;
}
.sourceMenu > div,
.sourceMenu > p {
font-size: 12px;
padding: 8px 4px;
}
.sourceMenu button {
width: 100%;
height: 36px;
justify-content: flex-start;
padding: 0 8px;
border-radius: 8px;
font-size: 13px;
color: #ddd;
}
.sourceMenu button span {
flex: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
text-align: left;
}
.error {
color: #fca5a5;
font-size: 12px;
padding: 4px;
}
@media (max-width: 500px) {
.settings {
left: 4px;
max-width: calc(100vw - 48px);
}
}

View File

@ -0,0 +1,301 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ArrowUp,
Check,
ChevronDown,
Maximize2,
Play,
Plus,
Scissors,
Upload,
X,
} from "lucide-react";
import { editRatios, editResolutions } from "@/lib/video-canvas/smart-edit/contract";
import styles from "./SmartEditNode.module.css";
export type SmartEditSource = { id: string; name: string; url: string; duration?: number };
const modes = [
{ id: "auto", name: "默认模式" },
{ id: "explain", name: "讲解视频" },
{ id: "ads", name: "批量广告" },
{ id: "talk", name: "口播视频" },
{ id: "montage", name: "素材混剪" },
];
export function SmartEditNodeCard({
title,
previewUrl,
onOpen,
selected = false,
}: {
title: string;
selected?: boolean;
previewUrl?: string;
onOpen: () => void;
}) {
return (
<div className={styles.card} style={{ borderColor: selected ? "#888" : "#454545" }}>
<div className={styles.title}>
<Play size={12} fill="currentColor" />
{title}
</div>
<div className={styles.preview}>
{previewUrl && <video src={`${previewUrl}#t=0.1`} muted preload="metadata" />}
<div className={styles.open}>
<Scissors size={36} />
<button onPointerDown={(e) => e.stopPropagation()} onClick={onOpen}>
打开智能剪辑
</button>
</div>
</div>
<div className={styles.ruler}>
{Array.from({ length: 12 }, (_, i) => (
<span key={i}>0:{String(i * 2).padStart(2, "0")}</span>
))}
</div>
<div className={styles.tracks}>
{[0, 1].map((row) => (
<div key={row}>
{Array.from({ length: 8 }, (_, i) => (
<i key={i} />
))}
</div>
))}
</div>
</div>
);
}
export function SmartEditNodeComposer({
anchor,
draft,
sources,
availableSources,
onDraft,
onAddSource,
onRemoveSource,
onUpload,
onReference,
onSubmit,
busy,
}: {
anchor: { centerX: number; bottomY: number; zoom: number };
draft: Record<string, any>;
sources: SmartEditSource[];
availableSources: SmartEditSource[];
onDraft: (patch: Record<string, unknown>) => void;
onAddSource: (id: string) => void;
onRemoveSource: (id: string) => void;
onUpload: (files: File[]) => void;
onReference?: () => void;
onSubmit: () => Promise<unknown>;
busy: boolean;
}) {
const [menu, setMenu] = useState<"mode" | "settings" | "sources" | null>(null),
[expanded, setExpanded] = useState(false),
[error, setError] = useState(""),
[submitting, setSubmitting] = useState(false);
const root = useRef<HTMLDivElement>(null),
file = useRef<HTMLInputElement>(null);
useEffect(() => {
const close = (e: PointerEvent) => {
if (!root.current?.contains(e.target as Node)) setMenu(null);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
const ratio = draft.ratio || "16:9",
resolution = draft.resolution || "1080p",
duration = draft.targetDuration ?? 20,
mode = modes.find((m) => m.id === draft.mode) || modes[0];
const toggle = (value: typeof menu) => setMenu(menu === value ? null : value);
return (
<div
ref={root}
className={styles.composer}
style={{ left: anchor.centerX, top: anchor.bottomY + 16 * anchor.zoom }}
onPointerDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className={styles.referenceRow}>
<button
onClick={() => (onReference ? onReference() : toggle("sources"))}
onContextMenu={(e) => {
e.preventDefault();
toggle("sources");
}}
>
<Plus size={14} />
参考
</button>
<button
aria-label={expanded ? "收起输入框" : "展开输入框"}
onClick={() => setExpanded(!expanded)}
>
<Maximize2 size={16} />
</button>
</div>
{!!sources.length && (
<div className={styles.sources}>
{sources.map((s, i) => (
<div className={styles.source} key={s.id} title={s.name}>
<video src={`${s.url}#t=0.1`} preload="metadata" muted />
<b>{i + 1}</b>
<Play size={12} fill="white" />
<span>{s.duration ? `${s.duration.toFixed(1)}s` : "视频"}</span>
<button aria-label={`移除 ${s.name}`} onClick={() => onRemoveSource(s.id)}>
<X size={10} />
</button>
</div>
))}
</div>
)}
<textarea
aria-label="智能剪辑要求"
placeholder="描述你想要的剪辑效果"
value={draft.prompt || ""}
onChange={(e) => onDraft({ prompt: e.target.value })}
style={{ height: expanded ? 192 : 96 }}
/>
<div className={styles.controls}>
<button className={styles.mode} onClick={() => toggle("mode")}>
{mode.name}
<ChevronDown size={12} />
</button>
<button className={styles.parameters} onClick={() => toggle("settings")}>
{ratio} · {String(resolution).toUpperCase()} · {duration ? `${duration}s` : "自动"}
<ChevronDown size={12} />
</button>
<button
className={styles.send}
disabled={busy || submitting || sources.length === 0}
aria-label="发送"
onClick={async () => {
setError("");
setSubmitting(true);
try {
await onSubmit();
} catch (e) {
setError(e instanceof Error ? e.message : "提交失败");
} finally {
setSubmitting(false);
}
}}
>
<ArrowUp size={20} />
</button>
</div>
{error && (
<div role="alert" className={styles.error}>
{error}
</div>
)}
{menu === "mode" && (
<div className={`${styles.popup} ${styles.modeMenu}`}>
{modes.map((m) => (
<button
key={m.id}
onClick={() => {
onDraft({ mode: m.id });
setMenu(null);
}}
>
{m.name}
{m.id === mode.id && <Check size={14} />}
</button>
))}
</div>
)}
{menu === "settings" && (
<div className={`${styles.popup} ${styles.settings}`}>
<label>比例</label>
<div className={styles.ratios}>
{editRatios.map((r) => {
const [w, h] = r.split(":").map(Number);
return (
<button key={r} aria-pressed={r === ratio} onClick={() => onDraft({ ratio: r })}>
<i
style={{
width: Math.min(16, (16 * w) / h),
height: Math.min(16, (16 * h) / w),
}}
/>
{r}
</button>
);
})}
</div>
<label htmlFor="smart-edit-duration">时长</label>
<div className={styles.duration}>
<input
aria-label="目标时长"
type="range"
min={0}
max={300}
step={1}
value={duration}
onChange={(e) => onDraft({ targetDuration: Number(e.target.value) })}
/>
<input
id="smart-edit-duration"
type="number"
min={0}
max={300}
value={duration}
onChange={(e) =>
onDraft({ targetDuration: Math.max(0, Math.min(300, Number(e.target.value))) })
}
/>
<span>s</span>
</div>
<label>清晰度</label>
<div className={styles.resolutions}>
{editResolutions.map((r) => (
<button
key={r}
aria-pressed={r === resolution}
onClick={() => onDraft({ resolution: r })}
>
{r.toUpperCase()}
</button>
))}
</div>
</div>
)}
{menu === "sources" && (
<div className={`${styles.popup} ${styles.sourceMenu}`}>
<button onClick={() => file.current?.click()} disabled={sources.length >= 12}>
<Upload size={14} />
上传视频
</button>
<div>画布素材 · {sources.length}/12</div>
{availableSources.map((s) => (
<button
key={s.id}
disabled={sources.length >= 12 && !sources.some((a) => a.id === s.id)}
onClick={() =>
sources.some((a) => a.id === s.id) ? onRemoveSource(s.id) : onAddSource(s.id)
}
>
<Play size={14} />
<span>{s.name}</span>
{sources.some((a) => a.id === s.id) && <Check size={14} />}
</button>
))}
{!availableSources.length && <p>上传视频或连接画布视频素材</p>}
</div>
)}
<input
ref={file}
type="file"
hidden
accept="video/*"
multiple
onChange={(e) => {
onUpload(Array.from(e.target.files || []).slice(0, 12 - sources.length));
e.target.value = "";
setMenu(null);
}}
/>
</div>
);
}

View File

@ -0,0 +1,32 @@
# 智能剪辑界面对照与验收(2026-09-17)
参考:LibTV 已登录画布中的智能剪辑节点及编辑器。通过可见 DOM、截图和实际点击测量,不读取参考站隐藏状态;没有提交参考站生成任务。
## 已逐项实现
| 功能 | 对照与实现 | 验证 |
| --- | --- | --- |
| 画布入口 | 566×350 节点、标题外置、暗化素材预览、剪刀图标、打开按钮、简化时间尺和轨道 | 本地实际组件截图检查;旧节点恢复时按新尺寸归一化 |
| 节点输入卡片 | 660 宽、16 圆角、48×48 素材缩略图、序号/时长/移除、提示词及发送 | 浏览器打开、参数修改;参考按钮连接画布视频,上限12份 |
| 五种模式 | 默认/讲解/批量广告/口播/素材混剪 | 映射已有方案服务;默认模式使用通用选片,广告多版仍是候选顺序变体 |
| 输出参数 | 六比例、480P/720P/1080P、0–300秒(0自动) | 浏览器选择21:9/480P/300s;六比例真实FFmpeg输出通过;远程21:9/1080P 2520×1080通过 |
| 编辑器默认视图 | 48px顶栏、导出/关闭、默认大预览、底部35vh时间轴 | 同窗口截图比较;左侧配置改为按需打开 |
| 视频参数 | 300px右栏、变速、时长、音量、暗色数值步进器 | 输入小数、选片及预览实际操作 |
| 时间轴 | 刻度、视频缩略图、音轨条、播放头、缩放、吸附、裁剪和分割 | 鼠标排序/边缘裁剪通过;修复拖动结束遗漏历史记录;一次撤销只撤回一次操作 |
| 字幕 | 列表/搜索/新建/时间范围/位置拖动、字体尺寸、间距、颜色、描边、透明度及样式预设 | 手动中文预览、实际ASS烧录;SRT保留文字,不把样式伪装成SRT能力 |
| 转场 | 左侧转场库、右侧时长/删除;淡入淡出/黑场/白场 | 真实成片含硬切混用;像素采样确认黑场变暗、白场变亮 |
| 导出 | “导出到本地/画布”菜单、提交阶段、关闭后重新打开恢复状态 | UI任务生命周期样例通过;真实远程接口回传与对象存储归档另行通过 |
| 打开已有连接 | 自动建立顺序时间轴,不调用视觉模型/Python分析 | 七素材顺序方案测试通过;不会覆盖用户选择的智能剪辑参数 |
| 任务标识 | 使用服务端实际接受的requestId;明确4xx拒绝不显示为持续导出 | 类型/回归检查,避免幂等返回旧任务后UI追踪新ID |
## 验证结果和边界
- 全量 helpers:488通过、1跳过(此批包含新增合同与远程宽屏结果测试)。
- 真实FFmpeg:两种全局转场、三种逐片段转场、中文字幕样式、六种480P比例全部通过。
- 真实300秒21:9/480P成片通过。AAC封装使实际容器时长300.022秒,输出探测允许0.2秒封装尾差,并仍要求成片与方案差异≤0.15秒;方案上限严格300秒。
- 真实远程2520×1080合成与回传通过;普通合成没有调用付费模型或本地千问。
- 浏览器验收使用独立本地入口加载真实React组件;任务回调样例与真正的Next API/服务器合成验证分开记录,不把样例回调当作生产端到端成功。
- 当前记录不等于“全站所有分辨率100%像素一致”:参考站素材、浏览器缩放、窗口尺寸会影响结果;主项目集成页及窄屏还需发布候选验证。字幕内核使用现有Noto CJK字体,不能承诺与参考站未授权字体逐字形相同。
- FunASR权重仍未安装,自动转写保持不可用;可导入SRT、手工编辑字幕。完整复杂对象多轨、音频等其他模块的状态不由这份记录扩大承诺。
部署状态以 `video-render-service-2026-09-17.md` 最后更新为准。

View File

@ -26,3 +26,9 @@
FunASR适配与安装脚本已实现,未装权重、未做真实ASR验收,界面禁用自动转写并支持导入SRT。当前本地千问接口不能替代视觉选片,视觉分析继续使用主项目已有模型配置。
部署边界:独立合成服务已启动且两机链路实测通过;主项目正式应用的切换状态将在发布验证后追加。此前音频/导演台等开发分支改动不能凭本记录视为已经上线。
## 2026-09-17 智能剪辑界面与输出范围补齐
用户再次要求按参考站像素级对齐。新增独立编辑器和画布节点输入卡片,逐项记录见 `smart-edit-ui-parity-2026-09-17.md`。新增手动字幕样式、逐片段三种转场、六种比例、480P和300秒上限,主项目与算力服务共用合同;修正远程返回结果旧90秒/1920限制。真实300秒输出发现AAC封装尾差,输出探测及校验保留0.2/0.15秒容差。
48候选应用仍运行独立目录 `/var/www/ai-chatbot-render-0917` 和回环3135,公网应用3125尚未切换。候选旧版已完成真实Next API鉴权、排队、选片、节拍、远程渲染和归档测试;新UI必须完成候选重建与验证后再更新此状态。不要将算力服务已部署理解成主站UI已上线。

View File

@ -7,6 +7,8 @@ import {
type CanvasTaskRequest,
} from "@/lib/video-canvas/task-contract";
class CanvasTaskRejectedError extends Error {}
type Node = CanvasTaskRequest["node"];
type Edge = CanvasTaskRequest["edges"][number];
const storageKey = (projectId: string) => `video-canvas-outbox:${projectId}`;
@ -115,6 +117,8 @@ export function useVideoCanvasTasks<T extends Node>(
),
);
}
if (response.status >= 400 && response.status < 500)
throw new CanvasTaskRejectedError(body.error || "任务参数未通过校验");
throw Error(body.error || "提交暂时失败,恢复连接后自动核对");
}
forget(projectId, request.requestId);
@ -175,13 +179,17 @@ export function useVideoCanvasTasks<T extends Node>(
remember(request);
setNodes((nodes) => nodes.map((item) => (item.id === node.id ? snapshot : item)));
try {
await send(request);
const accepted = await send(request);
refreshRef.current();
return accepted.requestId;
} catch (error) {
if (kind === "smart-edit" && error instanceof CanvasTaskRejectedError) throw error;
toast.error(
error instanceof Error ? error.message : "连接中断,恢复后将核对任务,请勿重复提交",
);
}
refreshRef.current();
return requestId;
},
[loaded, projectId, send, setNodes],
);

View File

@ -1,5 +1,8 @@
import { z } from "zod";
export const SECOND = 1_000_000;
export const MAX_EDIT_SECONDS = 300;
export const editRatios = ["21:9", "16:9", "9:16", "1:1", "4:3", "3:4"] as const;
export const editResolutions = ["480p", "720p", "1080p"] as const;
const time = z
.number()
.int()
@ -14,6 +17,36 @@ const url = z
export const subtitleSchema = z
.object({ start: time, end: time, text: z.string().max(500) })
.refine((x) => x.end > x.start);
export const captionStyleSchema = z.object({
fontSize: z.number().min(8).max(160).default(28),
color: z
.string()
.regex(/^#[0-9a-f]{6}$/i)
.default("#ffffff"),
outlineColor: z
.string()
.regex(/^#[0-9a-f]{6}$/i)
.default("#000000"),
outlineWidth: z.number().min(0).max(10).default(0),
opacity: z.number().min(0).max(1).default(1),
spacing: z.number().min(-5).max(30).default(0),
});
export const timelineCaptionSchema = z
.object({
id,
start: time,
end: time,
text: z.string().max(500),
style: captionStyleSchema.optional(),
})
.refine((x) => x.end > x.start);
export type TimelineCaption = z.infer<typeof timelineCaptionSchema>;
export type CaptionStyle = z.infer<typeof captionStyleSchema>;
export const defaultCaptionStyle: CaptionStyle = captionStyleSchema.parse({});
const transitionSchema = z.object({
duration: z.number().min(0).max(0.5),
type: z.enum(["fade", "fadeblack", "fadewhite"]),
});
export const assetInputSchema = z.object({
id,
url,
@ -39,17 +72,20 @@ export const editClipSchema = z
speed: z.number().min(0.5).max(2).default(1),
gain: z.number().min(0).max(1).default(1),
label: z.string().max(300).default(""),
// The transition belongs to the incoming clip, so it follows that clip when reordered.
transition: transitionSchema.optional(),
})
.refine((x) => x.sourceOut - x.sourceIn >= 200000);
export const editPlanSchema = z.object({
version: z.literal(1),
title: z.string().max(200),
assets: z.array(editAssetSchema).min(1).max(6),
assets: z.array(editAssetSchema).min(1).max(12),
clips: z.array(editClipSchema).min(1).max(12),
ratio: z.enum(["16:9", "9:16", "1:1"]),
resolution: z.enum(["720p", "1080p"]).default("720p"),
ratio: z.enum(editRatios),
resolution: z.enum(editResolutions).default("720p"),
transition: z.number().min(0).max(0.5).default(0),
subtitles: z.boolean().default(true),
captions: z.array(timelineCaptionSchema).max(500).optional(),
music: z
.object({ url, gain: z.number().min(0).max(1).default(0.2), start: time.default(0) })
.optional(),
@ -61,12 +97,12 @@ export type EditCandidate = z.infer<typeof candidateSchema>;
export const smartEditRequestSchema = z.discriminatedUnion("operation", [
z.object({
operation: z.literal("plan"),
assets: z.array(assetInputSchema).min(1).max(6),
mode: z.enum(["montage", "talk", "explain", "ads"]).default("montage"),
assets: z.array(assetInputSchema).min(1).max(12),
mode: z.enum(["sequence", "auto", "montage", "talk", "explain", "ads"]).default("montage"),
prompt: z.string().max(3000).default(""),
targetDuration: z.number().int().min(3).max(90).default(20),
ratio: z.enum(["16:9", "9:16", "1:1"]).default("16:9"),
resolution: z.enum(["720p", "1080p"]).default("720p"),
targetDuration: z.number().int().min(0).max(MAX_EDIT_SECONDS).default(20),
ratio: z.enum(editRatios).default("16:9"),
resolution: z.enum(editResolutions).default("720p"),
removeSilence: z.boolean().default(false),
transcribe: z.boolean().default(false),
semantic: z.boolean().default(true),
@ -85,6 +121,14 @@ export const smartEditRequestSchema = z.discriminatedUnion("operation", [
}),
]);
export type SmartEditRequest = z.infer<typeof smartEditRequestSchema>;
export function editOutputSize(plan: Pick<EditPlan, "ratio" | "resolution">) {
const size = Number.parseInt(plan.resolution, 10),
[w, h] = plan.ratio.split(":").map(Number);
const even = (v: number) => Math.round(v / 2) * 2;
return w >= h
? { width: even((size * w) / h), height: size }
: { width: size, height: even((size * h) / w) };
}
export function planTimeline(input: EditPlan) {
const plan = editPlanSchema.parse(input),
ids = new Set<string>();
@ -100,7 +144,7 @@ export function planTimeline(input: EditPlan) {
const previous = index ? plan.clips[index - 1] : null;
const overlap = previous
? Math.min(
Math.round(plan.transition * SECOND),
Math.round((clip.transition?.duration ?? plan.transition) * SECOND),
Math.floor(duration / 3),
Math.floor((previous.sourceOut - previous.sourceIn) / previous.speed / 3),
)
@ -108,15 +152,32 @@ export function planTimeline(input: EditPlan) {
cursor -= overlap;
const start = cursor;
cursor += duration;
return { ...clip, start, end: cursor, duration, overlap };
return {
...clip,
start,
end: cursor,
duration,
overlap,
transitionType: clip.transition?.type || "fade",
};
});
if (new Set(plan.assets.map((a) => a.id)).size !== plan.assets.length)
throw Error("素材 ID 重复");
if (cursor > 90 * SECOND || cursor < 200000) throw Error("成片时长需在 0.2–90 秒内");
if (plan.captions && new Set(plan.captions.map((s) => s.id)).size !== plan.captions.length)
throw Error("字幕 ID 重复");
if (cursor > MAX_EDIT_SECONDS * SECOND || cursor < 200000)
throw Error("成片时长需在 0.2–300 秒内");
return { clips, duration: cursor };
}
export function compiledSubtitles(plan: EditPlan) {
if (!plan.subtitles) return [];
if (plan.captions) {
const duration = planTimeline(plan).duration;
return plan.captions.flatMap((s) => {
const end = Math.min(s.end, duration);
return end > s.start ? [{ ...s, end }] : [];
});
}
return planTimeline(plan).clips.flatMap((clip) =>
plan.assets
.find((a) => a.id === clip.assetId)!
@ -134,6 +195,45 @@ export function compiledSubtitles(plan: EditPlan) {
}),
);
}
export function editableCaptions(plan: EditPlan): TimelineCaption[] {
if (plan.captions) return plan.captions;
return compiledSubtitles({ ...plan, subtitles: true }).map((s, i) => ({
...s,
id: `caption-${i}`,
}));
}
export function subtitlesToAss(plan: EditPlan, width: number, height: number) {
const stamp = (v: number) => {
const cs = Math.round(v / 10000);
return `${Math.floor(cs / 360000)}:${String(Math.floor(cs / 6000) % 60).padStart(2, "0")}:${String(Math.floor(cs / 100) % 60).padStart(2, "0")}.${String(cs % 100).padStart(2, "0")}`;
};
const color = (hex: string, opacity: number) =>
`&H${Math.round((1 - opacity) * 255)
.toString(16)
.padStart(2, "0")}${hex.slice(5, 7)}${hex.slice(3, 5)}${hex.slice(1, 3)}`;
// Style units use a 720px-high composition in both the browser and libass.
const rows = compiledSubtitles(plan) as Array<{
start: number;
end: number;
text: string;
style?: CaptionStyle;
}>;
const scale = height / 720;
const styles = rows.map((s, i) => {
const style = s.style || defaultCaptionStyle;
return `Style: c${i},Noto Sans CJK SC,${style.fontSize * scale},${color(style.color, style.opacity)},&H00FFFFFF,${color(style.outlineColor, style.opacity)},&HFF000000,0,0,0,0,100,100,${style.spacing * scale},0,1,${style.outlineWidth * scale},0,2,20,20,${Math.round(height * 0.05)},1`;
});
const events = rows.map((s, i) => {
const text = s.text
.replace(/\\/g, "\")
.replace(/\{/g, "{")
.replace(/\}/g, "}")
.replace(/\r/g, "")
.replace(/\n/g, "\\N");
return `Dialogue: 0,${stamp(s.start)},${stamp(s.end)},c${i},,0,0,0,,${text}`;
});
return `[Script Info]\nScriptType: v4.00+\nPlayResX: ${width}\nPlayResY: ${height}\nWrapStyle: 0\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n${styles.join("\n")}\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n${events.join("\n")}\n`;
}
export function subtitlesToSrt(subtitles: z.infer<typeof subtitleSchema>[]) {
const stamp = (v: number) => {
const ms = Math.round(v / 1000);
@ -198,18 +298,32 @@ export function buildEditPlans(
: ordered;
const clips: EditPlan["clips"] = [];
let duration = 0;
const target =
params.mode === "sequence"
? MAX_EDIT_SECONDS
: params.targetDuration ||
Math.min(
MAX_EDIT_SECONDS,
assets.reduce((sum, a) => sum + a.duration / SECOND, 0),
);
const visualLength = Math.max(5, Math.min(30, target / 12)) * SECOND;
for (const candidate of list) {
if (duration >= params.targetDuration * SECOND || clips.length >= 12) break;
if (duration >= target * SECOND || clips.length >= 12) break;
const available = candidate.end - candidate.start,
remaining = params.targetDuration * SECOND - duration;
remaining = target * SECOND - duration;
const speech = params.mode === "talk" || params.mode === "explain";
// Speech selections keep their sentence boundary even if it slightly misses requested length.
let length = speech ? available : Math.min(available, remaining, 5 * SECOND);
if (speech && duration + length > 90 * SECOND) continue;
if (params.beatSync && !speech && beats.length) {
let length =
params.mode === "sequence"
? Math.min(available, remaining)
: speech
? available
: Math.min(available, remaining, visualLength);
if (speech && duration + length > MAX_EDIT_SECONDS * SECOND) continue;
if (params.mode !== "sequence" && params.beatSync && !speech && beats.length) {
const stride = Number(params.beatsPerCut);
const desired = Math.min(
5 * SECOND,
visualLength,
available,
remaining / Math.max(1, Math.min(12 - clips.length, list.length - clips.length)),
);
@ -236,7 +350,7 @@ export function buildEditPlans(
}
const plan: EditPlan = {
version: 1,
title: `${{ montage: "素材混剪", talk: "口播视频", explain: "讲解视频", ads: "批量广告" }[params.mode]}${variant ? ` ${variant + 1}` : ""}`,
title: `${{ sequence: "智能剪辑", auto: "默认模式", montage: "素材混剪", talk: "口播视频", explain: "讲解视频", ads: "批量广告" }[params.mode]}${variant ? ` ${variant + 1}` : ""}`,
assets,
clips,
ratio: params.ratio,
@ -247,7 +361,13 @@ export function buildEditPlans(
warnings: [...warnings],
};
const actual = planTimeline(plan).duration / SECOND;
if (Math.abs(actual - params.targetDuration) > 0.5)
if (params.mode === "sequence" && assets.reduce((total, a) => total + a.duration, 0) > duration)
plan.warnings.push("时间轴已载入前 300 秒,超出的素材未加入;可在设置中另建剪辑方案。");
if (
params.mode !== "sequence" &&
params.targetDuration > 0 &&
Math.abs(actual - params.targetDuration) > 0.5
)
plan.warnings.push(
`目标 ${params.targetDuration} 秒,实际 ${actual.toFixed(2)} 秒;保留完整语句或受素材长度限制。`,
);

View File

@ -78,7 +78,7 @@ export async function executeSmartEdit(
active: lifecycle.assertActive,
save: (remote, stage) => save({ phase: "remote-render", remote, stage }),
});
const actual = await probeMedia(rendered.file);
const actual = await probeMedia(rendered.file, 300.2);
if (
Math.abs(actual.duration / SECOND - planTimeline(params.plan).duration / SECOND) > 0.15 ||
actual.width !== rendered.width ||
@ -135,22 +135,33 @@ export async function executeSmartEdit(
if (!meta.width || !meta.height) throw Error(`${input.name} 不包含视频画面`);
total += meta.duration;
if (total > 600 * SECOND) throw Error("素材总时长不能超过 10 分钟");
const prepared = await prepareAnalysis(
media.file,
media.hash,
media.dir,
meta.hasAudio,
);
const analysis = await pythonAnalysis(
{
version: 1,
video: prepared.proxy,
audio: prepared.audio,
duration: meta.duration,
transcribe: params.transcribe && !input.subtitles.length,
},
media.dir,
);
// Opening a timeline only probes source files; it must not run scene analysis or models.
const analysis =
params.mode === "sequence"
? {
scenes: [[0, meta.duration]],
active: [[0, meta.duration]],
subtitles: input.subtitles,
warnings: [],
}
: await (async () => {
const prepared = await prepareAnalysis(
media.file,
media.hash,
media.dir,
meta.hasAudio,
);
return pythonAnalysis(
{
version: 1,
video: prepared.proxy,
audio: prepared.audio,
duration: meta.duration,
transcribe: params.transcribe && !input.subtitles.length,
},
media.dir,
);
})();
const subtitles = input.subtitles.length ? input.subtitles : analysis.subtitles;
if (subtitles.some((s) => s.end > meta.duration + 100000))
throw Error(`${input.name} 的字幕时间超出视频长度`);
@ -183,9 +194,13 @@ export async function executeSmartEdit(
// Keep full speech sentences; split long visual scenes into bounded candidates.
for (let start = range.start; start < range.end; ) {
const end =
speech && subtitles.length
params.mode === "sequence" || (speech && subtitles.length)
? range.end
: Math.min(range.end, start + 5 * SECOND);
: Math.min(
range.end,
start +
Math.max(5, Math.min(30, (params.targetDuration || 20) / 12)) * SECOND,
);
if (end - start >= 200000)
candidates.push({
id: `candidate-${candidates.length}`,
@ -229,7 +244,7 @@ export async function executeSmartEdit(
}
if (!candidates.length) throw Error("没有可用片段,请关闭去静音或更换素材");
let order = candidates.map((c) => c.id);
if (params.semantic) {
if (params.semantic && params.mode !== "sequence") {
const images: string[] = [];
for (const candidate of candidates) {
await lifecycle.assertActive();
@ -274,7 +289,7 @@ export async function executeSmartEdit(
const plan = editPlanSchema.parse(params.plan);
planTimeline(plan);
const rendered = await renderJob(plan, userId, dir, (stage) => save({ stage }));
const actual = await probeMedia(rendered.file);
const actual = await probeMedia(rendered.file, 300.2);
if (Math.abs(actual.duration / SECOND - rendered.duration) > 0.15)
throw Error("导出时长与剪辑方案不一致,未发布结果");
await save({ stage: "转存成片" });

View File

@ -12,7 +12,7 @@ export const mediaSafety = [
"-format_whitelist",
"mov,matroska,webm,avi,mpeg,mpegts,flv,ogg,wav,mp3,flac,aac",
];
export async function ffmpeg(args: string[], cwd?: string) {
export async function ffmpeg(args: string[], cwd?: string, timeout = 240000) {
await exec(
"ffmpeg",
[
@ -28,10 +28,10 @@ export async function ffmpeg(args: string[], cwd?: string) {
"1",
...args,
],
{ cwd, timeout: 240000, maxBuffer: 2 * 1024 * 1024 },
{ cwd, timeout, maxBuffer: 2 * 1024 * 1024 },
);
}
export async function probeMedia(file: string) {
export async function probeMedia(file: string, maxSeconds = 180) {
const { stdout } = await exec(
"ffprobe",
["-v", "error", ...mediaSafety, "-show_streams", "-show_format", "-of", "json", file],
@ -40,8 +40,8 @@ export async function probeMedia(file: string) {
const info = JSON.parse(stdout),
v = info.streams?.find((s: any) => s.codec_type === "video");
const duration = Math.round(Number(info.format?.duration || v?.duration) * SECOND);
if (!Number.isFinite(duration) || duration <= 0 || duration > 180 * SECOND)
throw Error("单个素材需在 0–180 秒以内");
if (!Number.isFinite(duration) || duration <= 0 || duration > maxSeconds * SECOND)
throw Error(`媒体时长需在 0–${maxSeconds} 秒以内`);
if (v && (!v.width || !v.height || v.width * v.height > 33554432))
throw Error("素材画面尺寸超过处理上限");
return {

View File

@ -4,7 +4,7 @@ import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import path from "node:path";
import { z } from "zod";
import type { EditPlan } from "./contract";
import { MAX_EDIT_SECONDS, type EditPlan } from "./contract";
import { ProviderTaskUncertainError } from "../video-task-lifecycle";
export type RemoteRenderCheckpoint = { id: string; endpointHash: string };
@ -33,9 +33,12 @@ const statusSchema = z.object({
error: z.string().max(500).optional(),
result: z
.object({
width: z.number().int().positive().max(1920),
height: z.number().int().positive().max(1920),
durationSeconds: z.number().positive().max(90.2),
width: z.number().int().positive().max(2520),
height: z.number().int().positive().max(2520),
durationSeconds: z
.number()
.positive()
.max(MAX_EDIT_SECONDS + 0.2),
})
.optional(),
});

View File

@ -36,7 +36,7 @@ export async function renderJob(
musicFile = media.file;
}
const rendered = await renderEditPlan(plan, files, musicFile, dir, progress);
const actual = await probeMedia(rendered.file);
const actual = await probeMedia(rendered.file, 300.2);
if (Math.abs(actual.duration / SECOND - rendered.duration) > 0.15)
throw Error("导出时长与剪辑方案不一致,未发布结果");
return { ...rendered, duration: actual.duration / SECOND };

View File

@ -1,7 +1,16 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { type EditPlan, planTimeline, compiledSubtitles, subtitlesToSrt, SECOND } from "./contract";
import {
type EditPlan,
editOutputSize,
planTimeline,
compiledSubtitles,
subtitlesToSrt,
subtitlesToAss,
SECOND,
} from "./contract";
import { ffmpeg, mediaSafety } from "./local";
const renderFfmpeg = (args: string[], cwd?: string) => ffmpeg(args, cwd, 600000);
export async function renderEditPlan(
plan: EditPlan,
files: Record<string, string>,
@ -11,13 +20,7 @@ export async function renderEditPlan(
) {
const timeline = planTimeline(plan),
duration = timeline.duration / SECOND;
const size = plan.resolution === "1080p" ? 1080 : 720;
const [width, height] =
plan.ratio === "16:9"
? [Math.round((size * 16) / 9), size]
: plan.ratio === "9:16"
? [size, Math.round((size * 16) / 9)]
: [size, size];
const { width, height } = editOutputSize(plan);
const normalized: string[] = [];
for (let i = 0; i < timeline.clips.length; i++) {
await progress(`处理片段 ${i + 1}/${timeline.clips.length}`);
@ -35,7 +38,7 @@ export async function renderEditPlan(
files[clip.assetId],
];
if (!asset.hasAudio) inputs.push("-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo");
await ffmpeg([
await renderFfmpeg([
...inputs,
"-map",
"0:v:0",
@ -53,6 +56,10 @@ export async function renderEditPlan(
"veryfast",
"-crf",
"21",
"-maxrate",
"6M",
"-bufsize",
"12M",
"-threads",
"2",
"-c:a",
@ -67,27 +74,34 @@ export async function renderEditPlan(
}
await progress("合成时间轴");
const joined = path.join(dir, "joined.mp4");
if (plan.transition === 0 || normalized.length === 1) {
if (!timeline.clips.some((clip) => clip.overlap) || normalized.length === 1) {
await writeFile(
path.join(dir, "concat.txt"),
normalized.map((_, i) => `file 'clip-${i}.mp4'`).join("\n"),
);
await ffmpeg(["-f", "concat", "-safe", "1", "-i", "concat.txt", "-c", "copy", joined], dir);
await renderFfmpeg(
["-f", "concat", "-safe", "1", "-i", "concat.txt", "-c", "copy", joined],
dir,
);
} else {
// Normalize first: all xfade inputs have identical pixel format, timebase and dimensions.
const filters: string[] = [];
let video = "0:v",
const filters: string[] = normalized.map(
(_, i) => `[${i}:v]settb=AVTB,setpts=PTS-STARTPTS[vin${i}]`,
);
let video = "vin0",
audio = "0:a";
for (let i = 1; i < normalized.length; i++) {
const clip = timeline.clips[i];
filters.push(
`[${video}][${i}:v]xfade=transition=fade:duration=${clip.overlap / SECOND}:offset=${clip.start / SECOND}[v${i}]`,
`[${audio}][${i}:a]acrossfade=d=${clip.overlap / SECOND}:c1=tri:c2=tri[a${i}]`,
);
if (clip.overlap)
filters.push(
`[${video}][vin${i}]xfade=transition=${clip.transitionType}:duration=${clip.overlap / SECOND}:offset=${clip.start / SECOND}[v${i}]`,
`[${audio}][${i}:a]acrossfade=d=${clip.overlap / SECOND}:c1=tri:c2=tri[a${i}]`,
);
else filters.push(`[${video}][${audio}][vin${i}][${i}:a]concat=n=2:v=1:a=1[v${i}][a${i}]`);
video = `v${i}`;
audio = `a${i}`;
}
await ffmpeg([
await renderFfmpeg([
...normalized.flatMap((file) => ["-threads", "2", "-i", file]),
"-filter_complex",
filters.join(";"),
@ -103,6 +117,10 @@ export async function renderEditPlan(
"veryfast",
"-crf",
"21",
"-maxrate",
"6M",
"-bufsize",
"12M",
"-threads",
"2",
"-c:a",
@ -113,13 +131,11 @@ export async function renderEditPlan(
const subtitles = compiledSubtitles(plan);
const srt = path.join(dir, "subtitles.srt");
await writeFile(srt, subtitlesToSrt(subtitles));
await writeFile(path.join(dir, "subtitles.ass"), subtitlesToAss(plan, width, height));
const output = path.join(dir, "result.mp4");
const inputs = ["-i", joined];
const filters: string[] = [];
if (subtitles.length)
filters.push(
"[0:v]subtitles=filename=subtitles.srt:force_style='FontSize=22,Outline=2,MarginV=30'[v]",
);
if (subtitles.length) filters.push("[0:v]ass=filename=subtitles.ass[v]");
if (musicFile && plan.music) {
inputs.push("-ss", String(plan.music.start / SECOND), "-i", musicFile);
filters.push(
@ -128,7 +144,7 @@ export async function renderEditPlan(
);
}
await progress("输出成片");
await ffmpeg(
await renderFfmpeg(
[
...inputs,
...(filters.length ? ["-filter_complex", filters.join(";")] : []),
@ -138,7 +154,20 @@ export async function renderEditPlan(
musicFile && plan.music ? "[a]" : "0:a:0",
"-c:v",
subtitles.length ? "libx264" : "copy",
...(subtitles.length ? ["-preset", "veryfast", "-crf", "21", "-threads", "2"] : []),
...(subtitles.length
? [
"-preset",
"veryfast",
"-crf",
"21",
"-maxrate",
"6M",
"-bufsize",
"12M",
"-threads",
"2",
]
: []),
"-c:a",
"aac",
"-t",

View File

@ -128,6 +128,89 @@ const fs = require("node:fs"),
probe.duration / 1e6,
);
}
for (const type of ["fade", "fadeblack", "fadewhite"]) {
const out = path.join(dir, "manual-" + type);
fs.mkdirSync(out, { recursive: true });
const mixed = {
...plan,
subtitles: true,
clips: Array.from({ length: 4 }, (_, i) => ({
...plan.clips[0],
id: "mixed-" + i,
sourceIn: (i % 3) * 1000000,
sourceOut: ((i % 3) + 1) * 1000000,
transition: { type, duration: i === 2 ? 0 : 0.3 },
})),
captions: [
{
id: "manual",
start: 0,
end: 2000000,
text: "手动字幕 测试",
style: {
fontSize: 36,
color: "#ffff00",
outlineColor: "#000000",
outlineWidth: 2,
opacity: 1,
spacing: 2,
},
},
],
};
const result = await renderEditPlan(mixed, files, undefined, out);
const probe = await probeMedia(result.file);
assert.ok(Math.abs(probe.duration / 1e6 - 3.4) < 0.15, JSON.stringify(probe));
assert.match(fs.readFileSync(path.join(out, "subtitles.ass"), "utf8"), /Noto Sans CJK SC,36/);
assert.match(fs.readFileSync(result.srt, "utf8"), /手动字幕/);
const pixel = execFileSync("ffmpeg", [
"-v",
"error",
"-ss",
"0.833333",
"-i",
result.file,
"-frames:v",
"1",
"-vf",
"crop=2:2:400:200",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-",
]);
if (type === "fadeblack") assert.ok(pixel[0] < 70, "black transition must darken the midpoint");
if (type === "fadewhite")
assert.ok(pixel[1] > 180, "white transition must brighten the midpoint");
if (type === "fade")
assert.ok(pixel[0] > 220 && pixel[1] < 20, "crossfade must preserve equal red sources");
console.log(
"PASS: real " +
type +
" → hard cut → " +
type +
", styled manual caption, duration " +
probe.duration / 1e6,
);
}
for (const ratio of ["21:9", "16:9", "9:16", "1:1", "4:3", "3:4"]) {
const out = path.join(dir, "ratio-" + ratio.replace(":", "-"));
fs.mkdirSync(out, { recursive: true });
const result = await renderEditPlan(
{ ...plan, ratio, resolution: "480p", clips: [{ ...plan.clips[0], sourceOut: 1000000 }] },
files,
undefined,
out,
);
const probe = await probeMedia(result.file);
const [w, h] = ratio.split(":").map(Number);
assert.equal(Math.min(probe.width, probe.height), 480);
assert.ok(Math.abs(probe.width / probe.height - w / h) < 0.005, JSON.stringify(probe));
assert.equal(probe.width % 2, 0);
assert.equal(probe.height % 2, 0);
console.log("PASS: real 480P " + ratio + " " + probe.width + "×" + probe.height);
}
if (process.env.SMART_EDIT_PYTHON) {
const prepared = await prepareAnalysis(files.b, "fixture", dir, true);
const analysis = await pythonAnalysis(

View File

@ -30,7 +30,7 @@
任务 ID 由主项目持久任务号导出:重复 POST 返回同一记录,参数改变返回 409。写盘采用临时文件、fsync、rename 后再响应。单服务进程拥有此数据目录,不能启动多个副本共用目录。服务重启后运行中任务最多恢复两次;完成记录重启后仍可下载。主项目轮询超时保存编号继续查询,不受浏览器页面生命周期限制。
上限:请求 1 MiB,至多 30 个等待/运行任务,至多 200 个保留任务;完成/失败记录和产物保留 7 天,小时清理;原素材缓存按用户隔离,4 GiB / 7 天清理;素材 ≤125 MiB/份、≤180秒/份、≤6份且总长≤10分钟,成片≤90秒/12片段。磁盘可用不足10GiB拒绝重处理。外部素材禁止内网地址,DNS验证后绑定地址下载,每次重定向重新验证;FFmpeg禁用网络输入协议。
上限:请求 1 MiB,至多 30 个等待/运行任务,至多 200 个保留任务;完成/失败记录和产物保留 7 天,小时清理;原素材缓存按用户隔离,4 GiB / 7 天清理;素材 ≤125 MiB/份、≤180秒/份、≤12份且总长≤10分钟,成片≤300秒/12片段;6种比例(含21:9)、480P/720P/1080P,长边最高2520像素。磁盘可用不足10GiB拒绝重处理。外部素材禁止内网地址,DNS验证后绑定地址下载,每次重定向重新验证;FFmpeg禁用网络输入协议。
## 验证

View File

@ -5,6 +5,12 @@ import {
planTimeline,
compiledSubtitles,
subtitlesToSrt,
subtitlesToAss,
editableCaptions,
defaultCaptionStyle,
editOutputSize,
editRatios,
editResolutions,
parseSrt,
buildEditPlans,
SECOND,
@ -68,9 +74,9 @@ test("invalid source ranges, duplicate identity and overlong compositions cannot
p.clips[1].id = "c1";
expect(() => planTimeline(p)).toThrow("重复");
const over = plan();
over.assets[0] = { ...asset, duration: 100 * SECOND };
over.clips = [{ ...over.clips[0], sourceOut: 100 * SECOND }];
expect(() => planTimeline(over)).toThrow("90");
over.assets[0] = { ...asset, duration: 400 * SECOND };
over.clips = [{ ...over.clips[0], sourceOut: 400 * SECOND }];
expect(() => planTimeline(over)).toThrow("300");
});
test("spoken sentences are kept intact and missing footage is reported rather than repeated", () => {
const params = smartEditRequestSchema.parse({
@ -217,3 +223,143 @@ test("remote render records deterministic identity before network failure and re
else process.env.SMART_EDIT_RENDER_TOKEN = savedToken;
}
});
test("manual timeline captions survive source changes and cannot inject ASS commands", () => {
const p = plan();
const source = editableCaptions(p);
expect(source).toHaveLength(2);
p.captions = [
{
id: "manual",
start: 0,
end: 8 * SECOND,
text: "你好\n{\\pos(1,1)}\\N",
style: { ...defaultCaptionStyle, color: "#ffcc00", outlineWidth: 2 },
},
];
expect(compiledSubtitles(p)[0].end).toBe(4 * SECOND);
const ass = subtitlesToAss(p, 1280, 720);
expect(ass).toContain("Noto Sans CJK SC,28,&H0000ccff");
expect(ass).not.toContain("{\\pos");
expect(ass).toContain("你好\\N{\pos(1,1)}\N");
p.subtitles = false;
expect(compiledSubtitles(p)).toEqual([]);
expect(editableCaptions(p)).toHaveLength(1);
});
test("per-boundary transitions override the global fade and stay attached on reorder", () => {
const p = plan();
p.transition = 0.3;
p.clips[1].transition = { duration: 0.2, type: "fadeblack" };
expect(planTimeline(p).duration).toBe(3.8 * SECOND);
expect(planTimeline(p).clips[1].transitionType).toBe("fadeblack");
p.clips[1].transition.duration = 0;
expect(planTimeline(p).duration).toBe(4 * SECOND);
p.clips.reverse();
expect(planTimeline(p).clips[0].overlap).toBe(0);
p.captions = [
{ id: "same", start: 0, end: SECOND, text: "a" },
{ id: "same", start: SECOND, end: 2 * SECOND, text: "b" },
];
expect(() => planTimeline(p)).toThrow("字幕 ID 重复");
});
test("sequence import preserves all seven connected clips without AI selection or five-second truncation", () => {
const assets = Array.from({ length: 7 }, (_, i) => ({
...asset,
id: "source-" + i,
duration: 6 * SECOND,
}));
const params = smartEditRequestSchema.parse({
operation: "plan",
mode: "sequence",
assets,
semantic: false,
});
if (params.operation !== "plan") throw Error();
const candidates = assets.map((a) => ({
id: a.id,
assetId: a.id,
start: 0,
end: a.duration,
text: "",
}));
const result = buildEditPlans(
params,
assets,
candidates,
candidates.map((c) => c.id),
[],
[],
)[0];
expect(result.clips).toHaveLength(7);
expect(planTimeline(result).duration).toBe(42 * SECOND);
expect(result.warnings).toEqual([]);
});
test("all reference aspect ratios and resolutions produce even encoder dimensions", () => {
for (const ratio of editRatios)
for (const resolution of editResolutions) {
const size = editOutputSize({ ratio, resolution }),
[w, h] = ratio.split(":").map(Number);
expect(size.width % 2).toBe(0);
expect(size.height % 2).toBe(0);
expect(Math.min(size.width, size.height)).toBe(parseInt(resolution, 10));
expect(Math.abs(size.width / size.height - w / h)).toBeLessThan(0.005);
}
const p = plan();
p.assets = [{ ...asset, duration: 180 * SECOND }];
p.clips = [
{ ...p.clips[0], sourceOut: 150 * SECOND },
{ ...p.clips[0], id: "long2", sourceOut: 150 * SECOND },
];
expect(planTimeline(p).duration).toBe(300 * SECOND);
});
test("remote result accepts a five-minute ultrawide export and downloads the same job", async () => {
const { mkdtemp, readFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const dir = await mkdtemp(join(tmpdir(), "smart-edit-wide-"));
const oldFetch = globalThis.fetch,
savedURL = process.env.SMART_EDIT_RENDER_URL,
savedToken = process.env.SMART_EDIT_RENDER_TOKEN;
let id = "";
process.env.SMART_EDIT_RENDER_URL = "http://127.0.0.1:19399";
process.env.SMART_EDIT_RENDER_TOKEN = "x".repeat(64);
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
if (init?.method === "POST")
return Response.json({
id,
status: "succeeded",
stage: "完成",
result: { width: 2520, height: 1080, durationSeconds: 300 },
});
expect(String(input)).toContain(`/v1/renders/${id}/`);
return new Response(String(input).endsWith("/video") ? "fixture-video" : "字幕");
}) as typeof fetch;
try {
const result = await remoteRender(
{ ...plan(), ratio: "21:9", resolution: "1080p" },
"user",
"wide-task",
dir,
{
active: async () => {},
save: async (checkpoint) => {
id = checkpoint.id;
},
},
);
expect(result.width).toBe(2520);
expect(result.duration).toBe(300);
expect(await readFile(result.file, "utf8")).toBe("fixture-video");
expect(await readFile(result.srt, "utf8")).toBe("字幕");
} finally {
globalThis.fetch = oldFetch;
if (savedURL === undefined) delete process.env.SMART_EDIT_RENDER_URL;
else process.env.SMART_EDIT_RENDER_URL = savedURL;
if (savedToken === undefined) delete process.env.SMART_EDIT_RENDER_TOKEN;
else process.env.SMART_EDIT_RENDER_TOKEN = savedToken;
await rm(dir, { recursive: true, force: true });
}
});