Align smart edit canvas and timeline UI with reference workflow
This commit is contained in:
parent
41e2db9ca7
commit
f25e78e570
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
912
components/canvas/smart-edit/SmartEditEditor.module.css
Normal file
912
components/canvas/smart-edit/SmartEditEditor.module.css
Normal 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;
|
||||
}
|
||||
1664
components/canvas/smart-edit/SmartEditEditor.tsx
Normal file
1664
components/canvas/smart-edit/SmartEditEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
377
components/canvas/smart-edit/SmartEditNode.module.css
Normal file
377
components/canvas/smart-edit/SmartEditNode.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
301
components/canvas/smart-edit/SmartEditNode.tsx
Normal file
301
components/canvas/smart-edit/SmartEditNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
docs/smart-edit-ui-parity-2026-09-17.md
Normal file
32
docs/smart-edit-ui-parity-2026-09-17.md
Normal 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` 最后更新为准。
|
||||
@ -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已上线。
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
@ -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)} 秒;保留完整语句或受素材长度限制。`,
|
||||
);
|
||||
|
||||
@ -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: "转存成片" });
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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禁用网络输入协议。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user