Complete durable canvas groups and add isolated P40 video upscaling

This commit is contained in:
134 2026-09-18 12:14:45 +08:00
parent e8db66c6d5
commit cd9f340a89
33 changed files with 3260 additions and 155 deletions

4
.gitignore vendored
View File

@ -120,3 +120,7 @@ services/smart-edit/__pycache__/
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
.qa/
.next-video-*/
services/video-upscale/dist/
services/video-upscale/__pycache__/
services/video-upscale/vendor/__pycache__/

View File

@ -1,3 +1,4 @@
import { videoUpscaleSchema } from "@/lib/video-canvas/video-upscale-contract";
import { smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract";
import { audioRequestSchema } from "@/lib/video-canvas/audio-contract";
import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery";
@ -43,6 +44,7 @@ export async function POST(request: Request) {
throw Object.assign(Error("任务数据过大,请使用已上传的素材链接"), { status: 413 });
const parsed = canvasTaskRequestSchema.safeParse(JSON.parse(text));
if (!parsed.success) throw Object.assign(Error("任务参数不合法"), { status: 400 });
if(parsed.data.workflow)throw Object.assign(Error("编组任务必须使用整组提交接口"),{status:400});
if (
parsed.data.kind === "image-operation" &&
!imageOperationSchema.safeParse(parsed.data.params).success
@ -61,6 +63,7 @@ export async function POST(request: Request) {
!smartEditRequestSchema.safeParse(parsed.data.params).success
)
throw Object.assign(Error("剪辑参数无效"), { status: 400 });
if (parsed.data.kind === "video-upscale" && !videoUpscaleSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("视频高清参数无效"), {status:400});
const userId = await owner(parsed.data.projectId);
const task = await createCanvasGenerationTask(userId, parsed.data);
kick(userId, parsed.data.projectId);

View File

@ -0,0 +1,39 @@
import { auth } from "@/app/(auth)/auth";
import { after, NextResponse } from "next/server";
import { canvasTaskRequestSchema } from "@/lib/video-canvas/task-contract";
import {
createCanvasGroupTasks,
ownsTaskProject,
} from "@/lib/video-canvas/task-store";
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
export const runtime = "nodejs",
maxDuration = 900;
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
const text = await request.text();
if (Buffer.byteLength(text) > 1048576)
return NextResponse.json({ error: "编组数据过大" }, { status: 413 });
const parsed = canvasTaskRequestSchema
.array()
.min(1)
.max(40)
.safeParse(JSON.parse(text));
if (!parsed.success)
return NextResponse.json({ error: "编组参数无效" }, { status: 400 });
const projectId = parsed.data[0].projectId,
userId = session.user.id;
if (!(await ownsTaskProject(userId, projectId)))
return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const tasks = await createCanvasGroupTasks(userId, parsed.data);
after(() => runCanvasGenerationQueue(userId, projectId));
return NextResponse.json({ success: true, tasks }, { status: 202 });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "编组执行失败" },
{ status: Number((e as any)?.status) || 400 }
);
}
}

View File

@ -0,0 +1,40 @@
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { eq, and, sql, desc } from "drizzle-orm";
import { NextResponse } from "next/server";
export async function GET() {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
const rows = await db
.select({
projectId: canvasProject.id,
title: canvasProject.title,
templates: sql<any[]>`${canvasProject.canvasData}->'groupTemplates'`,
})
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "video"),
sql`${canvasProject.canvasData} ? 'groupTemplates'`
)
)
.orderBy(desc(canvasProject.updatedAt))
.limit(100);
return NextResponse.json(
{
templates: rows.flatMap((row) =>
Array.isArray(row.templates)
? row.templates.map((template) => ({
...template,
projectId: row.projectId,
projectTitle: row.title,
}))
: []
),
},
{ headers: { "Cache-Control": "no-store" } }
);
}

View File

@ -0,0 +1,39 @@
import { auth } from "@/app/(auth)/auth";
import { upscaleConfig } from "@/lib/video-canvas/video-upscale-remote";
import { NextResponse } from "next/server";
export async function GET() {
if (!(await auth())?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const config = upscaleConfig();
if (!config)
return NextResponse.json({
available: false,
message: "视频高清服务尚未接通",
});
const response = await fetch(config.base + "/health", {
headers: { authorization: "Bearer " + config.token },
signal: AbortSignal.timeout(5000),
redirect: "error",
cache: "no-store",
});
if (!response.ok) throw Error("unavailable");
const data = await response.json();
return NextResponse.json(
{
available: data.status === "ok",
engine: "Real-ESRGAN",
resolutions: ["1080P", "2K", "4K"],
maxScale: 4,
maxSeconds: 120,
queued: data.queued,
},
{ headers: { "Cache-Control": "no-store" } }
);
} catch {
return NextResponse.json(
{ available: false, message: "视频高清服务暂时不可用,请稍后重试" },
{ headers: { "Cache-Control": "no-store" } }
);
}
}

View File

@ -0,0 +1,125 @@
"use client";
import {
Download,
Play,
Ungroup,
Share2,
Grid3X3,
ScanLine,
} from "lucide-react";
export function CanvasGroupToolbar({
title,
color,
status,
busy,
storyboard,
canStoryboard,
onRename,
onColor,
onLayout,
onRun,
onUngroup,
onSave,
onDownload,
onStoryboard,
onCancel,
}: {
title: string;
color: string;
status: string;
busy: boolean;
storyboard: boolean;
canStoryboard: boolean;
onRename: (s: string) => void;
onColor: (s: string) => void;
onLayout: (s: "grid" | "horizontal" | "vertical") => void;
onRun: () => void;
onUngroup: () => void;
onSave: () => void;
onDownload: () => void;
onStoryboard: () => void;
onCancel: () => void;
}) {
return (
<div
data-canvas-overlay="true"
data-group-toolbar="true"
className="absolute left-1/2 top-[80px] z-[70] -translate-x-1/2 rounded-[22px] border border-white/10 bg-[#252525] p-4 text-white shadow-xl"
onPointerDown={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-5 whitespace-nowrap text-[16px]">
<input
aria-label="编组颜色"
type="color"
value={color}
onChange={(e) => onColor(e.target.value)}
className="h-7 w-7 cursor-pointer rounded-full border-0 bg-transparent"
/>
<label title="编组布局" className="flex items-center gap-1">
<Grid3X3 size={20} />
<select
aria-label="编组布局"
defaultValue=""
onChange={(e) => {
onLayout(e.target.value as any);
e.target.value = "";
}}
className="w-5 bg-[#252525]"
>
<option value="" disabled>
⌄
</option>
<option value="grid">网格布局</option>
<option value="horizontal">横向布局</option>
<option value="vertical">纵向布局</option>
</select>
</label>
<span className="h-8 w-px bg-white/10" />
<button
type="button"
disabled={busy}
onClick={onRun}
className="flex items-center gap-2 disabled:opacity-40"
>
<Play size={22} />
整组执行
</button>
<button onClick={onSave} className="flex items-center gap-2">
<Share2 size={22} />
添加到工具箱
</button>
<button
type="button"
disabled={!canStoryboard}
title={
canStoryboard ? "" : "分镜组需要全部成员都是已就绪的图片或视频"
}
onClick={onStoryboard}
className="flex items-center gap-2 disabled:opacity-35"
>
<ScanLine size={22} />
{storyboard ? "预览分镜" : "转分镜组"}
</button>
<button onClick={onUngroup} className="flex items-center gap-2">
<Ungroup size={22} />
解组
</button>
<span className="h-8 w-px bg-white/10" />
<button title="下载编组模板" onClick={onDownload}>
<Download size={22} />
</button>
</div>
<div className="mt-3 flex items-center justify-between gap-4 text-xs text-white/55">
<input
aria-label="编组名称"
value={title}
onChange={(e) => onRename(e.target.value)}
maxLength={120}
className="w-48 border-b border-white/10 bg-transparent py-1"
/>
<span>{status || "Shift 点击多选 · ⌘/Ctrl+G 编组"}</span>
{busy && <button onClick={onCancel}>取消未开始任务</button>}
</div>
</div>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowLeft, ArrowRight, X } from "lucide-react";
export type GroupShot = {
id: string;
title: string;
url: string;
type: "image" | "video";
};
export function GroupStoryboardPreview({
shots,
onOrder,
onClose,
}: {
shots: GroupShot[];
onOrder: (ids: string[]) => void;
onClose: () => void;
}) {
const [index, setIndex] = useState(0),
shot = shots[Math.min(index, shots.length - 1)];
useEffect(() => {
const key = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [onClose]);
if (!shot) return null;
const move = (delta: number) => {
const next = index + delta;
if (next < 0 || next >= shots.length) return;
const ids = shots.map((s) => s.id);
[ids[index], ids[next]] = [ids[next], ids[index]];
onOrder(ids);
setIndex(next);
};
return (
<div
data-canvas-overlay="true"
role="dialog"
aria-label="编组分镜预览"
className="fixed inset-0 z-[120] flex flex-col bg-black/95 p-8 text-white"
onPointerDown={(e) => e.stopPropagation()}
>
<header className="mb-4 flex items-center justify-between">
<h2>
分镜 {index + 1}/{shots.length} · {shot.title}
</h2>
<button aria-label="关闭分镜预览" onClick={onClose}>
<X />
</button>
</header>
<div className="flex min-h-0 flex-1 items-center justify-center">
{shot.type === "video" ? (
<video
key={shot.id}
src={shot.url}
controls
autoPlay
className="h-full max-w-full object-contain"
onEnded={() => setIndex((i) => Math.min(i + 1, shots.length - 1))}
/>
) : (
<img
src={shot.url}
alt={shot.title}
className="h-full max-w-full object-contain"
/>
)}
</div>
<div className="mt-4 flex items-center justify-center gap-6">
<button
type="button"
disabled={index === 0}
onClick={() => setIndex((i) => i - 1)}
className="disabled:opacity-30"
>
<ArrowLeft />
</button>
<span>
{index + 1} / {shots.length}
</span>
<button
type="button"
disabled={index === shots.length - 1}
onClick={() => setIndex((i) => i + 1)}
className="disabled:opacity-30"
>
<ArrowRight />
</button>
<button
type="button"
disabled={index === 0}
onClick={() => move(-1)}
className="disabled:opacity-30"
>
镜头前移
</button>
<button
type="button"
disabled={index === shots.length - 1}
onClick={() => move(1)}
className="disabled:opacity-30"
>
镜头后移
</button>
</div>
<div className="mt-5 flex justify-center gap-3 overflow-x-auto">
{shots.map((s, i) => (
<button
type="button"
key={s.id}
onClick={() => setIndex(i)}
className={`shrink-0 rounded-lg border px-4 py-2 text-sm ${i === index ? "border-white bg-white/20" : "border-white/20"}`}
>
{i + 1} · {s.title}
</button>
))}
</div>
</div>
);
}

View File

@ -1,4 +1,11 @@
"use client";
import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs";
import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery";
import { GroupStoryboardPreview } from "./GroupStoryboardPreview";
import { CanvasGroupToolbar } from "./CanvasGroupToolbar";
import { isCanvasGroup, groupMembers, makeCanvasGroup, ungroupCanvasNodes, moveCanvasSelection, layoutCanvasGroup, migrateLegacyGroups, exportCanvasGroup, instantiateCanvasGroup, groupBounds, assignGroupMembers } from "@/lib/video-canvas/groups";
import { compileGroupRun } from "@/lib/video-canvas/group-execution";
import { requireResolvedReferenceImages, connectionError, connectionTypeError, videoInputLimitError, connectedText, connectedInputNodes, assertConnectedInputsReady, withConnectedText, connectedImageUrls } from "@/lib/video-canvas/connections";
import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
import { SmartEditDialog } from "./smart-edit/SmartEditDialog";
@ -354,6 +361,7 @@ type DragState =
startY: number;
nodeX: number;
nodeY: number;
ids?: string[];
}
| {
kind: "canvas";
@ -2387,7 +2395,7 @@ function createVideoHdUpscaleNode({
action: "VIDEO_HD_UPSCALE",
frameInterpolation: option === "不补帧" ? "不补帧" : existingNode?.draftValues?.frameInterpolation || "不补帧",
generated: existingNode?.draftValues?.generated || false,
hdEngine: option === "Topazlabs" ? "Topazlabs" : existingNode?.draftValues?.hdEngine || "Topazlabs",
hdEngine: option === "Real-ESRGAN" ? "Real-ESRGAN" : existingNode?.draftValues?.hdEngine || "Real-ESRGAN",
hdResolution: resolution,
mediaType: "video",
nodeType: "video-hd",
@ -4618,6 +4626,15 @@ export function LiblibVideoCanvasReplica({
const activeGenerationSubmissions = useRef(new Set<string>());
const [edges, setEdges] = useState<CloneEdge[]>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [multiSelection, setMultiSelection] = useState<string[]>([]);
const [groupTemplates,setGroupTemplates]=useState<any[]>([]);
const {data:savedGroupTemplates}=useSWR<{templates:any[]}>("/api/canvas/group-templates",fetcher);
const [groupSubmitting,setGroupSubmitting]=useState(false);
const [storyboardGroupId,setStoryboardGroupId]=useState<string|null>(null);
const [selectionRect,setSelectionRect]=useState<{x:number;y:number;width:number;height:number}|null>(null);
const marqueeStart=useRef<{x:number;y:number}|null>(null);
const groupResize=useRef<{id:string;x:number;y:number;width:number;height:number;startX:number;startY:number;corner:string}|null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [lineConnectState, setLineConnectState] = useState<LineConnectState | null>(null);
const [referenceSelectionTargetId, setReferenceSelectionTargetId] = useState<string | null>(null);
@ -4645,6 +4662,7 @@ export function LiblibVideoCanvasReplica({
initialProjectRef.current = initialProject;
const [loadedProject, setLoadedProject] = useState<{ id: string; revision: string } | null>(null);
const generationTasks = useVideoCanvasTasks(canvasId, loadedProject, setNodes, setEdges, edges);
const groupRuns=useCanvasGroupRuns(canvasId,generationTasks.refresh);
const [, setShareEnabled] = useState(false);
const [publishPublic, setPublishPublic] = useState(false);
const [canvasCommand, setCanvasCommand] = useState<CanvasCommandState>(null);
@ -4752,7 +4770,9 @@ export function LiblibVideoCanvasReplica({
const data = project.canvasData as { viewport?: typeof DEFAULT_VIEWPORT; selectedNodeIds?: string[] } | undefined;
setProjectTitle(title);
setDraftTitle(title);
setNodes(restored);
setNodes(migrateLegacyGroups(restored));
setMultiSelection([]);
setGroupTemplates((project.canvasData as any)?.groupTemplates||[]);
setEdges(restoreEdges(project.canvasData));
const storedViewport = data?.viewport;
setViewport(storedViewport && [storedViewport.x, storedViewport.y, storedViewport.zoom].every(Number.isFinite)
@ -4836,12 +4856,13 @@ export function LiblibVideoCanvasReplica({
canvasData: {
nodes: serializeNodes(nodes),
edges: serializeEdges(edges),
selectedNodeIds: selectedNodeId ? [selectedNodeId] : [],
selectedNodeIds: multiSelection.length ? multiSelection : selectedNodeId ? [selectedNodeId] : [],
groupTemplates,
viewport,
tool: "select",
},
}),
[nodes, edges, selectedNodeId, viewport, projectTitle],
[nodes, edges, selectedNodeId, multiSelection, groupTemplates, viewport, projectTitle],
);
const applyMergedSnapshot = useCallback((value: string) => {
const merged = JSON.parse(value);
@ -4915,7 +4936,7 @@ export function LiblibVideoCanvasReplica({
const removeGenerationNode = useCallback(async (nodeId: string) => {
const node=nodes.find(item=>item.id===nodeId);
try {await generationTasks.dismiss(nodeId,getDraftString(node,"generationTaskId"));}catch(error){toast.error(error instanceof Error?error.message:"移除失败");return;}
setNodes((current) => current.filter((node) => node.id !== nodeId));
setNodes((current) => current.some(n=>n.id===nodeId&&isCanvasGroup(n))?ungroupCanvasNodes(current,nodeId):current.filter((node) => node.id !== nodeId));
setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
setSelectedNodeId((current) => (current === nodeId ? null : current));
setSelectedEdgeId(null);
@ -4997,7 +5018,7 @@ export function LiblibVideoCanvasReplica({
},
};
addNodes(
[group, reference, prompt, presetNode],
[{...group,draftValues:{...group.draftValues,groupVersion:1}}, ...[reference,prompt,presetNode].map(n=>({...n,draftValues:{...n.draftValues,parentGroupId:group.id}}))],
[
{ id: makeId("edge"), source: reference.id, target: prompt.id },
{ id: makeId("edge"), source: prompt.id, target: presetNode.id },
@ -8184,11 +8205,16 @@ export function LiblibVideoCanvasReplica({
[addNodes, nodes]
);
const generateVideoHdUpscaleNode = useCallback((nodeId: string) => {
const error = "当前没有已接通的视频超清模型,请先配置视频超分服务。";
setNodes(current => current.map(node => node.id === nodeId ? {...node, draftValues:{...node.draftValues,generated:false,generationStatus:"failed",generationError:error}} : node));
toast.error(error);
}, []);
const generateVideoHdUpscaleNode = useCallback(async (nodeId: string) => {
const node=nodes.find(n=>n.id===nodeId); if(!node)return;
try {
const incoming=connectedInputNodes(nodes,edges,nodeId); assertConnectedInputsReady(incoming);
const videos=incoming.filter(n=>n.inputType==="video");
if(videos.length!==1)throw Error("请连接一个已就绪的视频作为高清处理输入");
const url=getNodeMediaUrl(videos[0]);if(!url)throw Error("上游视频尚未生成");
await generationTasks.submit(node,"video-upscale",{url,resolution:getDraftString(node,"hdResolution")||"1080P"},edges.filter(e=>e.target===nodeId));
}catch(error){toast.error(error instanceof Error?error.message:"视频高清提交失败");}
}, [nodes,edges,generationTasks.submit]);
const applyVideoCropSelection = useCallback(async (nodeId: string) => {
const source = nodes.find(node => node.id === nodeId);
@ -8778,6 +8804,7 @@ export function LiblibVideoCanvasReplica({
setNodeContextMenu(null);
setConnectionMenu(null);
if (getDraftString(node,"action")==="VIDEO_HD_UPSCALE") {await generateVideoHdUpscaleNode(node.id);return;}
if (getTargetModuleKind(node) === "smart-edit") { setSmartEditNodeId(node.id); return; }
if (node.inputType === "audio" && node.draftValues?.audioParams) {
const incoming = readyCanvasInputs(nodes, edges, nodeId);
@ -9014,6 +9041,7 @@ export function LiblibVideoCanvasReplica({
edges,
nodes,
submitVideoTargetComposer,
generateVideoHdUpscaleNode,
videoCanvasFunctionConfig,
generationTasks.submit,
requestVideoAnalysis,
@ -9024,7 +9052,7 @@ export function LiblibVideoCanvasReplica({
const node=nodes.find(item=>item.id===nodeId);
if(getDraftString(node,"generationStatus")==="pending") {cancelGenerationNode(nodeId);return;}
try {await generationTasks.dismiss(nodeId,getDraftString(node,"generationTaskId"));}catch(error){toast.error(error instanceof Error?error.message:"移除失败");return;}
setNodes((current) => current.filter((node) => node.id !== nodeId));
setNodes((current) => current.some(n=>n.id===nodeId&&isCanvasGroup(n))?ungroupCanvasNodes(current,nodeId):current.filter((node) => node.id !== nodeId));
setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
setSelectedNodeId((current) => (current === nodeId ? null : current));
setSelectedEdgeId(null);
@ -9374,6 +9402,55 @@ export function LiblibVideoCanvasReplica({
[]
);
const selectedGroup=multiSelection.length<=1 && selectedNode && isCanvasGroup(selectedNode)?selectedNode:null;
const storyboardMembers=selectedGroup?groupMembers(nodes,selectedGroup.id,false):[];
const canGroupStoryboard=storyboardMembers.length>1&&storyboardMembers.every(n=>["image","video"].includes(n.inputType||"")&&!getTargetModuleKind(n)&&!!getNodeMediaUrl(n)&&!["pending","failed","interrupted"].includes(getDraftString(n,"generationStatus")));
const storyboardGroup=nodes.find(n=>n.id===storyboardGroupId),shotOrder=storyboardGroup?.draftValues?.shotNodeIds as string[]|undefined;
const previewShots=storyboardGroup?groupMembers(nodes,storyboardGroup.id,false).filter(n=>["image","video"].includes(n.inputType||"")&&getNodeMediaUrl(n)).sort((a,b)=>(shotOrder?.indexOf(a.id)??0)-(shotOrder?.indexOf(b.id)??0)).map(n=>({id:n.id,title:n.title,url:getNodeMediaUrl(n),type:n.inputType as "image"|"video"})):[];
const selectedGroupTasks=generationTasks.tasks.filter(t=>t.request.workflow?.groupId===selectedGroup?.id);
const latestGroupRun=selectedGroupTasks.at(-1)?.request.workflow?.runId;
const latestGroupTasks=selectedGroupTasks.filter(t=>t.request.workflow?.runId===latestGroupRun);
const groupBusy=groupSubmitting||groupRuns.submitting||latestGroupTasks.some(t=>["pending","running"].includes(t.status)||t.status==="interrupted"&&hasRecoverableCanvasTask(t));
const createSelectedGroup=useCallback(()=>{
try{const id=makeId("group");setNodes(makeCanvasGroup(nodes,multiSelection.length?multiSelection:selectedNodeId?[selectedNodeId]:[],id));setMultiSelection([]);setSelectedNodeId(id);}catch(e){toast.error((e as Error).message);}
},[nodes,multiSelection,selectedNodeId]);
const releaseGroup=useCallback(()=>{if(!selectedNodeId)return;setNodes(current=>ungroupCanvasNodes(current,selectedNodeId));setSelectedNodeId(null);setMultiSelection([]);},[selectedNodeId]);
const executeGroup=async()=>{
if(!selectedGroup||groupSubmitting)return;
setGroupSubmitting(true);
try {
const requests=compileGroupRun(nodes,edges,selectedGroup.id,canvasId,n=>{
const node=n as CloneNode,d=node.draftValues||{},p=getNodeParams(node),settings=(p.settings||{}) as Record<string,any>;
if(d.action==="VIDEO_HD_UPSCALE")return {kind:"video-upscale",params:{resolution:d.hdResolution||"1080P"}};
if(d.audioParams)return {kind:"audio",params:d.audioParams as Record<string,unknown>};
if(getTargetModuleKind(node)==="smart-edit"||d.nodeType==="smart-edit"){
const draft=(d.smartEditDraft||{}) as any,plan=draft.plans?.[draft.selectedPlanIndex||0]||d.editPlan;
const changes=edges.some(e=>e.target===node.id&&groupMembers(nodes,selectedGroup.id).some(m=>m.id===e.source&&(/GENERATE|UPSCALE/.test(String(m.draftValues?.action))||m.draftValues?.generationTaskId)));
if(plan&&!changes)return {kind:"smart-edit",params:{operation:"render",plan}};
return {kind:"smart-edit",params:{operation:"plan",autoRender:true,mode:draft.mode||"sequence",targetDuration:draft.targetDuration||300,ratio:draft.ratio||"16:9",resolution:draft.resolution||"720p",semantic:draft.semantic??false,prompt:draft.prompt||"",removeSilence:draft.removeSilence||false,transcribe:draft.transcribe||false,music:draft.music,beatSync:draft.beatSync||false,variants:1}};
}
const key=String(p.functionKey||d.functionKey||(node.inputType==="video"?"text-to-video":node.inputType==="image"?"":"story-script"));
const definition=key?getConfiguredFunction(videoCanvasFunctionConfig,key):{key:"image-generate",kind:"image-edit" as const,route:"canvas-generate-image" as const,enabled:true,label:"图片生成",prompt:"",description:""};
if(!definition?.enabled||!videoCanvasFunctionConfig.enabled)throw Error(`${node.title}:功能未启用或未配置`);
const model=getFunctionModel(videoCanvasFunctionConfig,definition,normalizeModelRef(settings.model||p.model));
const prompt=composeFunctionPrompt(definition,String(p.prompt||d.prompt||""));
if(node.inputType==="video")return {kind:"video",params:{prompt,preferredModelRef:model,mode:p.mode||settings.mode||(edges.some(e=>e.target===node.id&&nodes.find(n=>n.id===e.source)?.inputType==="image")?"first-frame":"text-to-video"),duration:Number(String(settings.duration||p.duration||5).replace(/s$/,"")),ratio:settings.ratio||p.ratio||"16:9",resolution:settings.resolution||p.resolution||"720p",generateAudio:settings.audio!=="关闭"}};
if(node.inputType==="image"){
if(["enhance","extend","remove-bg"].includes(definition.route||""))return {kind:"image-operation",params:{operation:definition.route,prompt}};
return {kind:"image",params:{prompt,model,allowFailover:false,imageCount:1,imageSize:settings.resolution||p.imageSize||"1K",aspectRatio:settings.ratio||p.ratio||"auto",billingFeature:getImageGenerationBillingFeature(key),promptExtend:true}};
}
if(node.inputType==="text"||node.kind==="script"||node.kind==="text")return {kind:"analysis",params:{prompt,preferredModelRef:model,mode:key==="story-script"?"story-script":key==="video-analysis"?"video-analysis":"agent-plan",nodeTitle:node.title}};
throw Error(`${node.title}:尚未配置可执行动作`);
},()=>crypto.randomUUID());
await groupRuns.submit(requests);
generationTasks.refresh();toast.success(`已提交 ${requests.length} 个节点,按连线依赖执行`);
}catch(e){toast.error((e as Error).message);}finally{setGroupSubmitting(false);}
};
const patchGroup=(patch:Record<string,unknown>)=>setNodes(current=>current.map(n=>n.id===selectedGroup?.id?{...n,draftValues:{...n.draftValues,...patch}}:n));
const saveGroupTemplate=()=>{if(!selectedGroup)return;const template=exportCanvasGroup(nodes,edges,selectedGroup.id);setGroupTemplates(current=>[...current.filter(t=>t.sourceId!==selectedGroup.id),{...template,sourceId:selectedGroup.id}].slice(-30));toast.success("已保存到我的工具箱,画布保存后可跨画布复用");};
const downloadGroup=()=>{if(!selectedGroup)return;const url=URL.createObjectURL(new Blob([JSON.stringify(exportCanvasGroup(nodes,edges,selectedGroup.id),null,2)],{type:"application/json"}));const a=document.createElement("a");a.href=url;a.download=`${selectedGroup.title.replace(/[\\/:*?"<>|]/g,"-")}.canvas-group.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
const importGroup=(template:any)=>{try{const value=instantiateCanvasGroup(template,()=>makeId("group-node"),Math.max(0,(200-viewport.x)/viewport.zoom),Math.max(0,(200-viewport.y)/viewport.zoom));setNodes(current=>[...current,...value.nodes as unknown as CloneNode[]]);setEdges(current=>[...current,...value.edges]);setSelectedNodeId(value.groupId);setMultiSelection([]);}catch(e){toast.error((e as Error).message);}};
const startNodeDrag = useCallback(
(event: ReactPointerEvent, node: CloneNode) => {
event.stopPropagation();
@ -9482,6 +9559,8 @@ export function LiblibVideoCanvasReplica({
return;
}
if(event.shiftKey){setMultiSelection(current=>{const ids=current.length?current:selectedNodeId?[selectedNodeId]:[];return ids.includes(node.id)?ids.filter(id=>id!==node.id):[...ids,node.id];});setSelectedNodeId(node.id);return;}
if(!multiSelection.includes(node.id))setMultiSelection([]);
event.currentTarget.setPointerCapture(event.pointerId);
setSelectedNodeId(node.id);
setSelectedEdgeId(null);
@ -9491,6 +9570,7 @@ export function LiblibVideoCanvasReplica({
dragRef.current = {
kind: "node",
id: node.id,
ids:multiSelection.includes(node.id)?multiSelection:[node.id],
startX: event.clientX,
startY: event.clientY,
nodeX: node.x,
@ -9499,6 +9579,7 @@ export function LiblibVideoCanvasReplica({
},
[
analyzeVideoCanvasMark,
multiSelection,selectedNodeId,
panMode, viewport.x, viewport.y,
connectExistingNodes,
getCanvasPoint,
@ -9512,6 +9593,8 @@ export function LiblibVideoCanvasReplica({
const startCanvasDrag = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return;
if(event.shiftKey){const point=getCanvasPoint(event.clientX,event.clientY);marqueeStart.current=point;setSelectionRect({...point,width:0,height:0});event.currentTarget.setPointerCapture(event.pointerId);return;}
setMultiSelection([]);
setSelectedNodeId(null);
setSelectedEdgeId(null);
setInspectorNodeId(null);
@ -9526,13 +9609,14 @@ export function LiblibVideoCanvasReplica({
viewportX: viewport.x,
viewportY: viewport.y,
};
}, [viewport.x, viewport.y]);
}, [viewport.x, viewport.y,getCanvasPoint]);
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.metaKey||event.ctrlKey)&&event.key.toLowerCase()==="g"){event.preventDefault();if(event.shiftKey&&selectedNodeId&&nodes.some(n=>n.id===selectedNodeId&&isCanvasGroup(n)))releaseGroup();else createSelectedGroup();return;}
if (event.code === "Space") { event.preventDefault(); spacePanRef.current = true; return; }
if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.toLowerCase() === "l") {
event.preventDefault();
@ -9583,10 +9667,12 @@ 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, smartEditNodeId]);
}, [createLineConnectState, createSelectedGroup, releaseGroup, deleteNode, deleteSelectedEdge, nodes, selectedEdgeId, selectedNodeId, smartEditNodeId]);
const onPointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if(marqueeStart.current){const start=marqueeStart.current,point=getCanvasPoint(event.clientX,event.clientY),rect={x:Math.min(start.x,point.x),y:Math.min(start.y,point.y),width:Math.abs(point.x-start.x),height:Math.abs(point.y-start.y)};setSelectionRect(rect);setMultiSelection(nodes.filter(n=>!isCanvasGroup(n)&&n.x>=rect.x&&n.y>=rect.y&&n.x+n.width<=rect.x+rect.width&&n.y+n.height<=rect.y+rect.height).map(n=>n.id));return;}
if(groupResize.current){const r=groupResize.current,dx=(event.clientX-r.startX)/viewport.zoom,dy=(event.clientY-r.startY)/viewport.zoom;setNodes(current=>current.map(n=>{if(n.id!==r.id)return n;const children=groupMembers(current,r.id,false),bound=groupBounds(children);const left=r.corner.includes("w")?Math.min(r.x+dx,children.length?bound.x:r.x+r.width-160):r.x,top=r.corner.includes("n")?Math.min(r.y+dy,children.length?bound.y:r.y+r.height-100):r.y;const right=r.corner.includes("e")?Math.max(r.x+r.width+dx,children.length?bound.x+bound.width:r.x+160):r.x+r.width,bottom=r.corner.includes("s")?Math.max(r.y+r.height+dy,children.length?bound.y+bound.height:r.y+100):r.y+r.height;return {...n,x:left,y:top,width:right-left,height:bottom-top};}));return;}
const endpointDrag = connectionDragRef.current;
if (endpointDrag) {
updateEndpointConnectionDrag(event.clientX, event.clientY);
@ -9613,13 +9699,7 @@ export function LiblibVideoCanvasReplica({
const gridSize = snapEnabled ? 8 : 1;
const nextX = Math.round((drag.nodeX + dx) / gridSize) * gridSize;
const nextY = Math.round((drag.nodeY + dy) / gridSize) * gridSize;
setNodes((current) =>
current.map((node) =>
node.id === drag.id
? { ...node, x: nextX, y: nextY }
: node
)
);
setNodes(current=>{const anchor=current.find(n=>n.id===drag.id);return anchor?moveCanvasSelection(current,drag.ids||[drag.id],nextX-anchor.x,nextY-anchor.y):current;});
return;
}
setViewport((current) => ({
@ -9628,10 +9708,11 @@ export function LiblibVideoCanvasReplica({
y: Math.round(drag.viewportY + event.clientY - drag.startY),
}));
},
[getCanvasPoint, getConnectionHoverNodeId, lineConnectState?.sourceId, lineConnectState?.targetId, snapEnabled, updateEndpointConnectionDrag, viewport.zoom]
[nodes,getCanvasPoint, getConnectionHoverNodeId, lineConnectState?.sourceId, lineConnectState?.targetId, snapEnabled, updateEndpointConnectionDrag, viewport.zoom]
);
const onPointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if(marqueeStart.current||groupResize.current){marqueeStart.current=null;groupResize.current=null;setSelectionRect(null);return;}
const endpointDrag = connectionDragRef.current;
if (endpointDrag) {
if (finishEndpointConnectionDrag(event.clientX, event.clientY)) {
@ -10031,6 +10112,19 @@ export function LiblibVideoCanvasReplica({
/>
) : null}
{storyboardGroupId&&<GroupStoryboardPreview shots={previewShots} onClose={()=>setStoryboardGroupId(null)} onOrder={ids=>setNodes(current=>{const changed=current.map(n=>n.id===storyboardGroupId?{...n,draftValues:{...n.draftValues,shotNodeIds:ids}}:n);const ordered=[...changed.filter(n=>!ids.includes(n.id)),...ids.map(id=>changed.find(n=>n.id===id)!).filter(Boolean)];return layoutCanvasGroup(ordered,storyboardGroupId,"horizontal");})}/>}
{selectedGroup&&<CanvasGroupToolbar title={selectedGroup.title} color={String(selectedGroup.draftValues?.groupColor||"#aaaaaa")} busy={groupBusy} storyboard={selectedGroup.draftValues?.groupMode==="storyboard"} canStoryboard={canGroupStoryboard} status={latestGroupTasks.length?`已完成 ${latestGroupTasks.filter(t=>t.status==="succeeded").length}/${latestGroupTasks.length} · 失败 ${latestGroupTasks.filter(t=>t.status==="failed").length}`:""}
onRename={title=>setNodes(current=>current.map(n=>n.id===selectedGroup.id?{...n,title}:n))} onColor={color=>patchGroup({groupColor:color})}
onLayout={layout=>setNodes(current=>layoutCanvasGroup(current,selectedGroup.id,layout))} onRun={()=>void executeGroup()} onUngroup={releaseGroup} onSave={saveGroupTemplate} onDownload={downloadGroup}
onStoryboard={()=>{if(!canGroupStoryboard)return;if(selectedGroup.draftValues?.groupMode!=="storyboard"){patchGroup({groupMode:"storyboard",shotNodeIds:storyboardMembers.sort((a,b)=>a.y-b.y||a.x-b.x).map(n=>n.id)});setNodes(current=>layoutCanvasGroup(current,selectedGroup.id,"horizontal"));}setStoryboardGroupId(selectedGroup.id);}}
onCancel={async()=>{for(const task of latestGroupTasks.filter(t=>t.status==="pending"))await generationTasks.cancel(task.id);}}/>}
{multiSelection.length>1&&!selectedGroup&&<button data-canvas-overlay="true" onClick={createSelectedGroup} className="absolute left-1/2 top-24 z-[70] rounded-xl border border-white/10 bg-[#252525] px-6 py-3 text-white">编组({multiSelection.length} 个节点)</button>}
{!selectedGroup&&(multiSelection.length>0||selectedNodeId)&&<div data-canvas-overlay="true" className="absolute right-5 top-[130px] z-[65] rounded-xl border border-white/10 bg-[#252525] p-2 text-xs text-white"><select aria-label="移动到编组" defaultValue="" className="bg-[#252525]" onChange={e=>{const id=e.target.value;try{setNodes(assignGroupMembers(nodes,multiSelection.length?multiSelection:selectedNodeId?[selectedNodeId]:[],id==="__none"?"":id));}catch(error){toast.error((error as Error).message);}e.target.value="";}}><option value="" disabled>移动到编组…</option><option value="__none">移出编组</option>{nodes.filter(isCanvasGroup).map(n=><option key={n.id} value={n.id}>{n.title}</option>)}</select></div>}
<details data-canvas-overlay="true" className="absolute right-5 top-20 z-[65] max-h-80 w-52 overflow-auto rounded-xl border border-white/10 bg-[#252525] p-3 text-sm text-white"><summary className="cursor-pointer">我的编组工具箱</summary>
{groupTemplates.map((template,index)=><div key={index} className="mt-2 flex gap-2"><button className="flex-1 truncate text-left" onClick={()=>importGroup(template)}>{template.name}</button><button title="删除模板" onClick={()=>setGroupTemplates(current=>current.filter((_,i)=>i!==index))}>×</button></div>)}
{(savedGroupTemplates?.templates||[]).filter(t=>t.projectId!==canvasId).map((template,index)=><button key={`remote-${index}`} className="mt-2 block w-full truncate text-left" title={template.projectTitle} onClick={()=>importGroup(template)}>{template.name}</button>)}
<label className="mt-3 block cursor-pointer text-white/60">导入编组模板<input type="file" accept=".json" className="hidden" onChange={async e=>{const file=e.target.files?.[0];e.target.value="";if(!file)return;if(file.size>1048576){toast.error("模板不能超过 1 MiB");return;}try{importGroup(JSON.parse(await file.text()));}catch{toast.error("模板格式无效");}}}/></label>
</details>
<input ref={replaceFileInputRef} type="file" className="hidden" onChange={async event=>{
const file=event.target.files?.[0],nodeId=replaceFileNodeId.current;event.target.value="";
if(!file||!nodeId)return;
@ -10068,14 +10162,15 @@ export function LiblibVideoCanvasReplica({
preview={lineConnectPreview}
selectedEdgeId={selectedEdgeId}
/>
{nodes.map((node) => (
{selectionRect&&<div className="pointer-events-none absolute z-50 border border-blue-400 bg-blue-400/10" style={{left:selectionRect.x,top:selectionRect.y,width:selectionRect.width,height:selectionRect.height}}/>}
{[...nodes.filter(isCanvasGroup).sort((a,b)=>b.width*b.height-a.width*a.height),...nodes.filter(n=>!isCanvasGroup(n))].map((node) => (
<CloneCanvasNode
connectionsHidden={edgesHidden}
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}
selected={multiSelection.length?multiSelection.includes(node.id):node.id === selectedNodeId}
onContextMenu={(event) => openNodeContext(event, node)}
onCancelGeneration={() => cancelGenerationNode(node.id)}
onChooseFile={()=>{replaceFileNodeId.current=node.id;if(replaceFileInputRef.current){replaceFileInputRef.current.accept=node.inputType==="audio"?"audio/*":node.inputType==="video"?"video/*":"image/*";replaceFileInputRef.current.click();}}}
@ -10087,7 +10182,11 @@ export function LiblibVideoCanvasReplica({
onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)}
onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)}
onOpenScriptWorkflow={() => openStoryScriptWorkflow(node.id)}
onPointerDown={(event) => startNodeDrag(event, node)}
onPointerDown={(event) => {
const corner=(event.target as HTMLElement).dataset.groupResize;
if(corner){event.stopPropagation();event.currentTarget.setPointerCapture(event.pointerId);groupResize.current={id:node.id,x:node.x,y:node.y,width:node.width,height:node.height,startX:event.clientX,startY:event.clientY,corner};return;}
startNodeDrag(event,node);
}}
onStartConnectionDrag={(event, side) => startConnectionEndpointDrag(event, node, side)}
onStartConnectionMouseDrag={(event, side) => startConnectionEndpointMouseDrag(event, node, side)}
onRunNode={() => {
@ -12355,10 +12454,10 @@ function VideoHdSettingsPopover({ onSelect }: { onSelect: (option: string) => vo
<div className="absolute left-0 top-[calc(100%+10px)] z-50 w-[267px] rounded-[12px] border border-white/10 bg-[#202020]/98 p-2 text-white shadow-[0_18px_54px_rgba(0,0,0,0.48)] backdrop-blur-[16px]">
<button
className="mb-2 flex h-8 w-full items-center justify-between rounded-[8px] border border-white/10 bg-white/[0.03] px-3 text-[13px] text-white/82 hover:bg-white/8"
onClick={() => onSelect("Topazlabs")}
onClick={() => onSelect("Real-ESRGAN")}
type="button"
>
<span>Topazlabs</span>
<span>Real-ESRGAN</span>
<ChevronDown className="h-3.5 w-3.5 text-white/46" />
</button>
<div className="mb-2 flex gap-1">
@ -12915,13 +13014,17 @@ function VideoHdUpscaleCanvasNode({
onGenerate: () => void;
selected: boolean;
}) {
const [previewError,setPreviewError]=useState(false);
const {data:upscaleCapability}=useSWR<{available:boolean;message?:string}>("/api/canvas/video-upscale/capabilities",fetcher,{refreshInterval:30000});
const generationStatus = getDraftString(node, "generationStatus");
const isGenerating = generationStatus === "pending";
const generated = Boolean(node.draftValues?.generated);
const generated = Boolean(node.draftValues?.generated) && !isGenerating;
const mediaUrl = getNodeMediaUrl(node);
useEffect(()=>setPreviewError(false),[mediaUrl]);
const progress = getDraftNumber(node, "generationProgress", 0);
const resolution = getDraftString(node, "hdResolution") || "1080P";
const outputSizeLabel = getDraftString(node, "outputSizeLabel") || "1920 × 1080";
const meta=node.draftValues?.videoMeta as any;
const outputSizeLabel = meta?.width&&meta?.height?`${meta.width} × ${meta.height}`:getDraftString(node, "outputSizeLabel") || resolution;
return (
<div className="h-full w-full" data-video-hd-node="true">
@ -12943,12 +13046,15 @@ function VideoHdUpscaleCanvasNode({
</div>
<div className="relative min-h-0 flex-1 bg-black">
<video
className="h-full w-full object-cover"
controls
className="h-full w-full object-contain"
loop
muted
playsInline
src={mediaUrl}
onError={()=>setPreviewError(true)}
/>
{previewError&&<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[#202020] px-5 text-center text-sm text-white/70"><span>预览加载失败,高清结果已保存</span><a href={mediaUrl} target="_blank" rel="noreferrer" className="rounded-lg bg-white/10 px-4 py-2 text-white">打开或下载视频</a></div>}
<span className="absolute left-3 top-3 rounded-[6px] bg-black/62 px-2 py-1 text-[11px] text-white/76">
{resolution}
</span>
@ -12966,7 +13072,7 @@ function VideoHdUpscaleCanvasNode({
</span>
</div>
<div className="absolute inset-x-0 top-[180px] text-center text-[16px] text-white/42">
配置参数
{getDraftString(node,"generationError")||getDraftString(node,"pendingLabel")||upscaleCapability?.message||`${resolution} · AI 超分 · 保留音频与帧率`}
</div>
<div className="absolute inset-x-0 bottom-9 flex justify-center">
<button
@ -12974,13 +13080,13 @@ function VideoHdUpscaleCanvasNode({
"flex h-10 items-center gap-2 rounded-[10px] bg-white px-4 text-[14px] font-semibold text-black transition hover:bg-white/90",
isGenerating && "cursor-wait opacity-70"
)}
disabled={isGenerating}
disabled={isGenerating||upscaleCapability?.available===false}
onClick={onGenerate}
onPointerDown={(event) => event.stopPropagation()}
type="button"
>
{isGenerating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{isGenerating ? "生成中" : "生成高清视频"}
{isGenerating ? "生成中" : upscaleCapability?.available===false ? "服务未就绪" : "生成高清视频"}
</button>
</div>
{isGenerating ? (
@ -18383,7 +18489,7 @@ function CloneCanvasNode({
selected: boolean;
}) {
if (getDraftString(node, "action") === "TOOLBOX_TEMPLATE_GROUP") {
return <ToolboxTemplateGroupCanvasNode node={node} selected={selected} />;
return <ToolboxTemplateGroupCanvasNode node={node} selected={selected} onPointerDown={onPointerDown} onContextMenu={onContextMenu}/>;
}
if (
@ -18551,23 +18657,12 @@ function CloneCanvasNode({
);
}
function ToolboxTemplateGroupCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) {
return (
<div
className={cn(
"react-flow__node react-flow__node-group nopan selectable parent draggable pointer-events-none absolute z-[1] rounded-[20px] border bg-white/10 text-white/72",
selected ? "selected border-[#363636]" : "border-white/10"
)}
data-canvas-node="true"
data-canvas-node-id={node.id}
data-canvas-overlay="true"
style={{ height: node.height, left: node.x, top: node.y, width: node.width }}
>
<div className="absolute left-5 top-5 text-[120px] font-semibold leading-none text-white/46">
{node.title}
</div>
</div>
);
function ToolboxTemplateGroupCanvasNode({ node, selected, onPointerDown, onContextMenu }: { node: CloneNode; selected: boolean; onPointerDown:(event:ReactPointerEvent<HTMLDivElement>)=>void;onContextMenu:(event:ReactMouseEvent<HTMLDivElement>)=>void }) {
const color=String(node.draftValues?.groupColor||"#aaaaaa");
return <div className="pointer-events-none absolute z-[-1] border bg-[#2a2a2a]/85 select-none" data-canvas-node="true" data-canvas-node-id={node.id} data-canvas-overlay="true" style={{height:node.height,left:node.x,top:node.y,width:node.width,borderColor:selected?color:"#555"}}>
<div className="pointer-events-auto absolute -top-8 left-0 flex h-7 w-full cursor-grab items-center text-[16px] text-white/55" onPointerDown={onPointerDown} onContextMenu={onContextMenu}>{node.draftValues?.groupMode==="storyboard"?"分镜组 · ":""}{node.title}</div>
{selected&&["nw","ne","sw","se"].map(corner=><div key={corner} data-group-resize={corner} className="pointer-events-auto absolute h-3 w-3 rounded-[2px] bg-[#aaa]" onPointerDown={onPointerDown} style={{left:corner.includes("w")?-6:undefined,right:corner.includes("e")?-6:undefined,top:corner.includes("n")?-6:undefined,bottom:corner.includes("s")?-6:undefined,cursor:corner==="nw"||corner==="se"?"nwse-resize":"nesw-resize"}}/>)}
</div>;
}
function MaterialStyleCanvasNode({

View File

@ -0,0 +1,39 @@
# 编组和独立视频高清验收 — 2026-09-18
工作分支 `codex/video-canvas-parity-0916`。本次没有更新主站前端/主项目版本。
## 编组
- Shift 点击/框选、Ctrl/⌘+G、新建组、嵌套成员、整体移动、四角调整边框、颜色/名称、网格/横排/竖排、移动到组/移出、解组保留节点和连线。旧预设背景框按几何包含关系迁移一次,后续按明确 parentGroupId 工作。
- 全部就绪图片/视频可转分镜组,预览、前后镜头、调整顺序;混合工具或未就绪素材时禁用转换。顺序保存在画布中,导出重建 ID。
- 我的工具箱保存到所属画布,按用户权限跨画布查询;模板 JSON 导入/导出。内部节点和连线重建 ID,运行编号清除;外部媒体依赖转换为必填输入节点,缺素材时整批拒绝。角色/特效外部依赖要求先纳入组内。
- 整组执行一次性原子入库,客户端持久 outbox 使用同一 runId/requestId 重试;关闭/刷新浏览器后服务端继续执行。按真实连线获取本次上游结果,拒绝循环、错误类型、缺输入、跨批次依赖。上游失败/取消/无可恢复编号的中断会阻断下游,不取旧输出。
- 支持现有图片/视频/文字分析/音频/图片处理/视频高清任务。智能剪辑遇到本次新视频时拆成隐藏选片阶段 + 成片阶段,依赖新生成素材;保留已确认且输入未变的现有剪辑方案。默认新组合使用顺序剪辑,已有智能参数按保存配置。取消只取消尚未提交的任务。
- 选中宫格切片需先落成独立图片节点再整组执行,有明确拦截,未用整张原图冒充切片。
## 视频高清
Real-ESRGAN 独立部署到算力服务器物理 GPU 3(P40)。保留千问 GPU 0/1,GPU 2 不使用。服务与 48→算力私有 API 隧道均启用;48 的 `/etc/video-upscale-client.env` 已准备,尚未注入/发布主项目版本。部署和约束见 `services/video-upscale/README.md`。
真实样例均由色彩图案/正弦音合成,无付费模型:
| 输入 | 输出 | 验证 |
| --- | --- | --- |
| 960×540、2fps、1秒、音轨 | 3840×2160 | 2帧,2fps,音轨保留,1.002秒 |
| 270×480、2fps、1秒、无音轨 | 1080×1920 | 2帧,2fps,1秒 |
| 960×540 + 90度旋转信息、含音轨 | 1080×1920 | 归一化方向,音轨/帧率/帧数保留 |
| 主项目编组提交 960×540 | 2560×1440 | 真实 GPU → 持久任务成功 → 本地对象存储归档 → 页面恢复 src |
第一轮横屏/竖屏短片含传输与轮询分别约 8.4/6.9 秒。仅是 1 秒 2 帧链路测试,不能推算常见 24/30fps 长视频吞吐。
重启测试发现并修复两处问题:128核机器 FFmpeg 默认滤镜线程超过服务限额;SIGTERM 杀子进程时曾将正在处理的任务记成 failed。修复后同一任务重启续跑,attempts=2,返回真实 4K 结果。未鉴权 401、重复请求复用记录、参数冲突 409 均通过。
## 验证证据与边界
- 75 项定向回归通过,包含连线、编组、任务恢复、智能剪辑;新增组内自动选片/成片依赖测试。
- 浏览器实测:多选编组、布局、整体拖动、调整边框、模板保存导入/必填输入拦截、分镜转换/预览/排序、真实提交参数依赖、刷新恢复排队、解组保留成员。
- 队列读取仅查询当前待处理任务及直接依赖,避免每次 claim 读取整个项目历史。
- 真实 PostgreSQL:原子批量提交、幂等、冲突拒绝、项目所有权;上游失败时下游 attempts=0。
- 真实主项目 API:2K 成片归档成功,FFprobe 验证 H.264/AAC、2560×1440、帧数/时长;刷新后保留结果链接。
- 当前验收 Chrome 对 `127.0.0.1:19440` 本地对象存储的媒体加载返回 `Media load rejected by URL safety check`。没有绕过浏览器策略;网页实际播放不能记为通过。界面增加明确“结果已保存、预览加载失败”的提示与打开链接,避免黑屏被理解为仍在处理。尚需在获准访问的正式/测试对象存储域名上完成播放验收后发布主项目。
- 本地证据保存在忽略目录 `.qa/group-upscale-0918/`,可复用 GPU 验证脚本 `scripts/qa/video-upscale-service.cjs`。正式运行代码/文档不包含密钥或 QA 账号。

View File

@ -0,0 +1,90 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
canvasTaskRequestSchema,
type CanvasTaskRequest,
} from "@/lib/video-canvas/task-contract";
import { toast } from "sonner";
export function useCanvasGroupRuns(projectId: string, refresh: () => void) {
const [submitting, setSubmitting] = useState(false),
active = useRef(projectId),
refreshRef = useRef(refresh),
sending = useRef(false);
active.current = projectId;
refreshRef.current = refresh;
const key = `video-canvas-group-outbox:${projectId}`;
const read = useCallback(() => {
try {
const parsed = canvasTaskRequestSchema
.array()
.min(1)
.max(40)
.safeParse(JSON.parse(localStorage.getItem(key) || "null"));
return parsed.success &&
parsed.data.every((r) => r.projectId === projectId && r.workflow)
? parsed.data
: null;
} catch {
return null;
}
}, [key, projectId]);
const send = useCallback(
async (requests: CanvasTaskRequest[]) => {
if (sending.current) return;
sending.current = true;
setSubmitting(true);
try {
const response = await fetch("/api/canvas/group-runs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requests),
signal: AbortSignal.timeout(20000),
});
if (!response.ok) {
if (response.status < 500 && response.status !== 429) {
localStorage.removeItem(key);
const body = await response.json();
throw Error(body.error || "编组请求被拒绝");
}
throw Error("编组提交尚未确认,已保存,将查询同一批任务");
}
localStorage.removeItem(key);
if (active.current === projectId) refreshRef.current();
} finally {
sending.current = false;
if (active.current === projectId) setSubmitting(false);
}
},
[key, projectId]
);
const submit = useCallback(
async (requests: CanvasTaskRequest[]) => {
const pending = read();
if (pending && pending[0].workflow?.runId !== requests[0].workflow?.runId)
throw Error("上一批编组提交仍在确认,请稍后重试");
localStorage.setItem(key, JSON.stringify(requests));
await send(requests);
},
[key, read, send]
);
useEffect(() => {
setSubmitting(false);
const retry = () => {
const requests = read();
if (requests && !sending.current)
void send(requests).catch((error) => {
if (!read()) toast.error(error.message);
});
};
retry();
const timer = setInterval(retry, 10000);
window.addEventListener("online", retry);
window.addEventListener("focus", retry);
return () => {
clearInterval(timer);
window.removeEventListener("online", retry);
window.removeEventListener("focus", retry);
};
}, [read, send]);
return { submit, submitting };
}

View File

@ -89,7 +89,7 @@ export function connectionTypeError(source: ConnectionNode, target: ConnectionNo
const nodeType = draft.nodeType;
if (source.kind === "material-effect" || source.kind === "material-role")
return target.inputType === "video" && nodeType !== "smart-edit" ? null : "角色和视频特效素材只能连接到视频生成节点";
const allowed = nodeType === "smart-edit" || nodeType === "video-clip" ? ["video"]
const allowed = nodeType === "smart-edit" || nodeType === "video-clip" || draft.action === "VIDEO_HD_UPSCALE" ? ["video"]
: target.inputType === "image" ? ["image", "text"]
: target.inputType === "video" ? (key === "audio-to-video" ? ["image", "text", "audio"] : ["image", "text"])
: target.inputType === "audio" ? ["text"]

View File

@ -0,0 +1,333 @@
import {
assertConnectedInputsReady,
connectedInputNodes,
connectedText,
connectionTypeError,
videoInputLimitError,
withConnectedText,
type ConnectionNode,
type CanvasConnection,
} from "./connections";
import { groupMembers, isCanvasGroup, type GroupNode } from "./groups";
import { hasRecoverableCanvasTask } from "./task-recovery";
import type { CanvasTaskRequest, CanvasGenerationTask } from "./task-contract";
export function executableGroupNode(node: GroupNode) {
const d = node.draftValues || {},
action = String(d.action || "");
if (isCanvasGroup(node) || node.kind?.startsWith("material-")) return false;
if (
d.generationTaskId ||
d.audioParams ||
/GENERATE|UPSCALE|STORY_ANALYSIS|SMART_EDIT/.test(action)
)
return true;
if (
/_RESOURCE$/.test(action) ||
(node.kind === "text" &&
connectedText(node) &&
!d.params?.prompt &&
!d.prompt)
)
return false;
return (
["model", "workflow", "script", "preset"].includes(node.kind || "") ||
!!d.params?.prompt
);
}
export function compileGroupRun(
nodes: GroupNode[],
edges: CanvasConnection[],
groupId: string,
projectId: string,
prepare: (node: GroupNode) => Pick<CanvasTaskRequest, "kind" | "params">,
uuid: () => string
) {
const members = groupMembers(nodes, groupId).filter(executableGroupNode);
if (!members.length) throw Error("编组没有可执行节点");
if (members.length > 40) throw Error("整组最多执行 40 个节点");
const runId = uuid(),
ids = new Map(members.map((n) => [n.id, uuid()]));
const requests: CanvasTaskRequest[] = members.map((node) => {
const inputs = connectedInputNodes(nodes, edges, node.id).map((source) => {
const error = connectionTypeError(source, node);
if (error) throw Error(`${node.title}:${error}`);
if (!ids.has(source.id)) assertConnectedInputsReady([source]);
if (source.draftValues?.gridSplit?.selectedIndices?.length)
throw Error("编组执行前请将选中的宫格保存为独立图片节点");
return { node: source, requestId: ids.get(source.id) };
});
return {
...prepare(node),
requestId: ids.get(node.id)!,
projectId,
node: node as CanvasTaskRequest["node"],
edges: edges.filter((e) => e.target === node.id),
workflow: { runId, groupId, inputs },
};
});
const expanded = requests.flatMap((request) => {
if (request.kind !== "smart-edit" || request.params.autoRender !== true)
return [request];
const planNodeId = `group-plan-${uuid()}`,
planRequestId = uuid(),
planNode = {
...request.node,
id: planNodeId,
title: `${request.node.title} · 选片方案`,
};
const { autoRender, ...params } = request.params;
const plan = {
...request,
node: planNode,
requestId: planRequestId,
params,
hiddenNodeIds: [planNodeId],
edges: request.edges.map((e) => ({
...e,
id: uuid(),
target: planNodeId,
})),
};
const render = {
...request,
params: { operation: "render" },
hiddenNodeIds: [planNodeId],
workflow: {
...request.workflow!,
inputs: [
{
node: planNode,
requestId: planRequestId,
output: "edit-plan" as const,
},
],
},
edges: [{ id: uuid(), source: planNodeId, target: request.node.id }],
};
return [plan, render];
});
if (expanded.length > 40)
throw Error("包含选片阶段后整组超过 40 个任务,请拆分编组");
validateGroupBatch(expanded);
return expanded;
}
export function validateGroupBatch(requests: CanvasTaskRequest[]) {
if (!requests.length || requests.length > 40) throw Error("整组任务数量无效");
const first = requests[0],
map = new Map(requests.map((r) => [r.requestId, r]));
if (
map.size !== requests.length ||
new Set(requests.map((r) => r.node.id)).size !== requests.length
)
throw Error("重复的整组任务");
for (const r of requests) {
if (
!r.workflow ||
r.projectId !== first.projectId ||
r.workflow.runId !== first.workflow?.runId ||
r.workflow.groupId !== first.workflow?.groupId
)
throw Error("整组任务不属于同一画布和编组");
for (const input of r.workflow.inputs) {
if (
!r.edges.some(
(e) => e.source === input.node.id && e.target === r.node.id
)
)
throw Error("依赖缺少对应连线");
if (input.output === "edit-plan") {
const source = input.requestId ? map.get(input.requestId) : null;
if (
r.kind !== "smart-edit" ||
r.params.operation !== "render" ||
source?.kind !== "smart-edit" ||
source.params.operation !== "plan"
)
throw Error("剪辑方案依赖无效");
} else {
const error = connectionTypeError(input.node, r.node);
if (error) throw Error(error);
}
if (
input.requestId &&
map.get(input.requestId)?.node.id !== input.node.id
)
throw Error("依赖不属于本次编组执行");
if (!input.requestId) assertConnectedInputsReady([input.node]);
}
if (
r.edges.some(
(e) =>
e.target !== r.node.id ||
!r.workflow!.inputs.some((i) => i.node.id === e.source)
)
)
throw Error("连线缺少对应输入");
}
const visited = new Set<string>(),
visiting = new Set<string>();
const visit = (id: string) => {
if (visiting.has(id)) throw Error("编组存在循环依赖");
if (visited.has(id)) return;
visiting.add(id);
for (const input of map.get(id)!.workflow!.inputs)
if (input.requestId) visit(input.requestId);
visiting.delete(id);
visited.add(id);
};
for (const id of map.keys()) visit(id);
}
export function groupDependencyState(
request: CanvasTaskRequest,
tasks: CanvasGenerationTask[]
) {
const dependencies =
request.workflow?.inputs
.filter((i) => i.requestId)
.map((i) =>
tasks.find(
(t) =>
t.requestId === i.requestId &&
t.request.workflow?.runId === request.workflow?.runId
)
) || [];
if (
dependencies.some(
(t) =>
!t ||
["failed", "cancelled"].includes(t.status) ||
(t.status === "interrupted" && !hasRecoverableCanvasTask(t))
)
)
return "failed";
return dependencies.every((t) => t?.status === "succeeded")
? "ready"
: "waiting";
}
export function resolveGroupInputs(
request: CanvasTaskRequest,
tasks: CanvasGenerationTask[]
): Record<string, unknown> {
if (!request.workflow) return request.params;
if (groupDependencyState(request, tasks) !== "ready")
throw Error("编组上游任务尚未成功完成");
const planInput = request.workflow.inputs.find(
(i) => i.output === "edit-plan"
);
if (planInput) {
const plan = tasks.find((t) => t.requestId === planInput.requestId)?.result
?.plans?.[0];
if (!plan) throw Error("上游未返回剪辑方案");
return { ...request.params, plan };
}
const nodes = request.workflow.inputs.map((input) => {
if (!input.requestId) return input.node;
const task = tasks.find((t) => t.requestId === input.requestId)!;
const data = task.result || {};
const urls =
task.request.kind === "image" || task.request.kind === "image-operation"
? (data.images || [])
.map((i: any) => i.originalUrl || i.thumbnailUrl)
.filter(Boolean)
: data.url
? [data.url]
: [];
const text = data.raw || data.summary || (data.agentPlan || []).join("\n");
return {
...input.node,
draftValues: {
...input.node.draftValues,
generationStatus: "complete",
mediaUrl: urls[0],
url: urls,
content: text,
rows: data.analysisRows || data.shotIdeas,
},
};
});
assertConnectedInputsReady(nodes);
const urls = (type: string) =>
nodes
.filter((n) => n.inputType === type)
.flatMap((n) =>
n.draftValues?.url?.length
? n.draftValues.url
: [
n.draftValues?.mediaUrl ||
n.draftValues?.resultImage ||
n.draftValues?.thumbnailUrl,
]
)
.filter(Boolean);
const roleImages = nodes
.filter((n) => n.kind === "material-role" && n.inputType !== "image")
.map((n) => n.draftValues?.thumbnailUrl || n.draftValues?.mediaUrl)
.filter(Boolean);
const images = [...urls("image"), ...roleImages],
videos = urls("video"),
audio = urls("audio");
const p: Record<string, unknown> = {
...request.params,
prompt: withConnectedText(String(request.params.prompt || ""), nodes),
};
if (request.kind === "image")
return {
...p,
referenceImage: images[0],
referenceImages: images.slice(1),
};
if (request.kind === "video") {
const error = videoInputLimitError(String(p.mode), images.length);
if (error) throw Error(error);
return {
...p,
prompt: [
p.prompt,
...nodes.map(
(n) => n.draftValues?.rolePrompt || n.draftValues?.effectPrompt
),
]
.filter(Boolean)
.join("\n\n"),
images,
audioUrls: audio,
};
}
if (request.kind === "analysis")
return { ...p, imageUrls: images, videoUrls: videos };
if (request.kind === "audio")
return {
...p,
text: [request.params.text, ...nodes.map(connectedText)]
.filter(Boolean)
.join("\n\n"),
};
if (request.kind === "video-upscale") {
if (videos.length !== 1) throw Error("视频高清需要一个上游视频");
return { ...p, url: videos[0] };
}
if (request.kind === "image-operation") {
if (images.length !== 1) throw Error("图片处理需要一个上游图片");
return { ...p, imageUrl: images[0] };
}
if (request.kind === "smart-edit") {
if (request.params.operation === "plan") {
if (!videos.length) throw Error("智能剪辑没有已就绪的视频输入");
return {
...p,
assets: videos.map((url: string, i: number) => ({
id: `group-asset-${i + 1}`,
name:
nodes.filter((n) => n.inputType === "video")[i]?.title ||
`素材 ${i + 1}`,
url,
})),
};
}
const plan = request.params.plan as any;
if (videos.some((url) => !plan?.assets?.some((a: any) => a.url === url)))
throw Error("剪辑方案与当前连线素材不一致,请重新确认方案");
return p;
}
throw Error("编组节点暂不支持执行");
}

398
lib/video-canvas/groups.ts Normal file
View File

@ -0,0 +1,398 @@
import { connectionError } from "./connections";
import type { CanvasConnection, ConnectionNode } from "./connections";
export type GroupNode = ConnectionNode & {
x: number;
y: number;
width: number;
height: number;
title: string;
};
export const isCanvasGroup = (node: ConnectionNode) =>
node.draftValues?.action === "TOOLBOX_TEMPLATE_GROUP";
export const parentGroup = (node: ConnectionNode) =>
String(node.draftValues?.parentGroupId || "");
export function groupMembers<T extends GroupNode>(
nodes: T[],
groupId: string,
deep = true
): T[] {
const result: T[] = [],
seen = new Set([groupId]);
const visit = (id: string) => {
for (const node of nodes.filter((n) => parentGroup(n) === id)) {
if (seen.has(node.id)) throw Error("编组包含循环成员关系");
seen.add(node.id);
result.push(node);
if (deep && isCanvasGroup(node)) visit(node.id);
}
};
visit(groupId);
return result;
}
export function groupBounds(nodes: GroupNode[]) {
if (!nodes.length) return { x: 0, y: 0, width: 400, height: 260 };
const x = Math.min(...nodes.map((n) => n.x)) - 32,
y = Math.min(...nodes.map((n) => n.y)) - 48;
return {
x,
y,
width: Math.max(...nodes.map((n) => n.x + n.width)) - x + 32,
height: Math.max(...nodes.map((n) => n.y + n.height)) - y + 32,
};
}
export function selectedRoots<T extends GroupNode>(nodes: T[], ids: string[]) {
const selected = new Set(ids);
return nodes.filter(
(node) =>
selected.has(node.id) &&
!nodes.some(
(group) =>
selected.has(group.id) &&
isCanvasGroup(group) &&
groupMembers(nodes, group.id).some((member) => member.id === node.id)
)
);
}
export function makeCanvasGroup<T extends GroupNode>(
nodes: T[],
ids: string[],
id: string,
title = "未命名编组"
) {
const members = selectedRoots(nodes, ids);
if (members.length < 2) throw Error("请至少选择两个节点进行编组");
const parents = new Set(members.map(parentGroup)),
parent = parents.size === 1 ? parentGroup(members[0]) : "";
const group = {
id,
kind: "model",
title,
...groupBounds(members),
accent: "#a0a0a0",
draftValues: {
action: "TOOLBOX_TEMPLATE_GROUP",
nodeType: "group",
parentGroupId: parent,
groupColor: "#a0a0a0",
groupVersion: 1,
},
};
const selected = new Set(members.map((n) => n.id));
return [
...nodes.map((n) =>
selected.has(n.id)
? { ...n, draftValues: { ...n.draftValues, parentGroupId: id } }
: n
),
group as unknown as T,
];
}
export function ungroupCanvasNodes<T extends GroupNode>(
nodes: T[],
groupId: string
) {
const group = nodes.find((n) => n.id === groupId);
if (!group || !isCanvasGroup(group)) throw Error("未找到编组");
return nodes
.filter((n) => n.id !== groupId)
.map((n) =>
parentGroup(n) === groupId
? {
...n,
draftValues: {
...n.draftValues,
parentGroupId: parentGroup(group),
},
}
: n
);
}
export function moveCanvasSelection<T extends GroupNode>(
nodes: T[],
ids: string[],
dx: number,
dy: number
) {
const moving = new Set(ids);
for (const id of ids)
if (nodes.some((n) => n.id === id && isCanvasGroup(n)))
for (const child of groupMembers(nodes, id)) moving.add(child.id);
let result = nodes.map((n) =>
moving.has(n.id) ? { ...n, x: n.x + dx, y: n.y + dy } : n
);
// Expand stationary ancestor frames when a member is dragged outside them.
for (let pass = 0; pass < nodes.length; pass++) {
let changed = false;
result = result.map((group) => {
if (!isCanvasGroup(group) || moving.has(group.id)) return group;
const children = groupMembers(result, group.id, false);
if (!children.length) return group;
const bound = groupBounds(children),
x = Math.min(group.x, bound.x),
y = Math.min(group.y, bound.y);
const width = Math.max(group.x + group.width, bound.x + bound.width) - x,
height = Math.max(group.y + group.height, bound.y + bound.height) - y;
if (
x === group.x &&
y === group.y &&
width === group.width &&
height === group.height
)
return group;
changed = true;
return { ...group, x, y, width, height };
});
if (!changed) break;
}
return result;
}
export function layoutCanvasGroup<T extends GroupNode>(
nodes: T[],
groupId: string,
layout: "grid" | "horizontal" | "vertical"
) {
const group = nodes.find((n) => n.id === groupId);
if (!group) throw Error("编组不存在");
const members = groupMembers(nodes, groupId, false);
const cols =
layout === "horizontal"
? members.length
: layout === "vertical"
? 1
: Math.max(1, Math.ceil(Math.sqrt(members.length)));
const cellWidth = Math.max(180, ...members.map((n) => n.width)) + 48,
cellHeight = Math.max(120, ...members.map((n) => n.height)) + 64;
let result = nodes;
for (const [i, member] of members.entries())
result = moveCanvasSelection(
result,
[member.id],
group.x + 32 + (i % cols) * cellWidth - member.x,
group.y + 48 + Math.floor(i / cols) * cellHeight - member.y
);
const bounds = groupBounds(groupMembers(result, groupId, false));
return result.map((n) => (n.id === groupId ? { ...n, ...bounds } : n));
}
export function migrateLegacyGroups<T extends GroupNode>(nodes: T[]) {
// Old preset frames stored geometry but no membership. Infer once on load.
const groups = nodes.filter(
(n) => isCanvasGroup(n) && !n.draftValues?.groupVersion
);
return nodes.map((n) => {
if (isCanvasGroup(n))
return { ...n, draftValues: { ...n.draftValues, groupVersion: 1 } };
if (parentGroup(n)) return n;
const enclosing = groups
.filter(
(g) =>
n.x >= g.x &&
n.y >= g.y &&
n.x + n.width <= g.x + g.width &&
n.y + n.height <= g.y + g.height
)
.sort((a, b) => a.width * a.height - b.width * b.height)[0];
return enclosing
? { ...n, draftValues: { ...n.draftValues, parentGroupId: enclosing.id } }
: n;
});
}
export function exportCanvasGroup<T extends GroupNode>(
nodes: T[],
edges: CanvasConnection[],
groupId: string
) {
const group = nodes.find((n) => n.id === groupId);
if (!group) throw Error("编组不存在");
let all = [group, ...groupMembers(nodes, groupId)];
const ids = new Set(all.map((n) => n.id));
const externalIds = [
...new Set(
edges
.filter((e) => ids.has(e.target) && !ids.has(e.source))
.map((e) => e.source)
),
];
const inputs = externalIds.map((id, index) => {
const source = nodes.find((n) => n.id === id);
if (
!source ||
!["image", "video", "audio", "text"].includes(source.inputType || "")
)
throw Error("请先将组外角色、特效等上游节点加入编组,再保存模板");
return {
id,
title: `输入:${source.title}`.slice(0, 120),
kind: "input",
inputType: source.inputType,
x: group.x + 32,
y: group.y + 48 + index * 210,
width: 240,
height: 160,
accent: "#aaaaaa",
draftValues: {
action: `${source.inputType!.toUpperCase()}_RESOURCE`,
parentGroupId: group.id,
mediaType: source.inputType,
url: [],
content: [],
generated: false,
generationStatus: "idle",
templateInput: true,
},
} as unknown as T;
});
if (inputs.length) {
all = all.map((n) =>
n.id === group.id
? {
...n,
width: n.width + 280,
height: Math.max(n.height, inputs.length * 210 + 80),
}
: { ...n, x: n.x + 280 }
);
all.push(...inputs);
for (const input of inputs) ids.add(input.id);
}
return {
version: 1,
name: group.title,
nodes: all.map((n) => ({
...n,
draftValues: {
...n.draftValues,
params: n.draftValues?.params
? {
...n.draftValues.params,
taskId: undefined,
sourceNodeId: undefined,
}
: undefined,
...(n.id === groupId ? { parentGroupId: "" } : {}),
generationTaskId: undefined,
taskRequestId: undefined,
appliedTaskStatus: undefined,
generationStatus:
n.draftValues?.mediaUrl || n.draftValues?.content
? "complete"
: "idle",
generationError: undefined,
pendingLabel: undefined,
},
})),
edges: edges.filter((e) => ids.has(e.source) && ids.has(e.target)),
};
}
export function instantiateCanvasGroup<T extends GroupNode>(
template: { nodes: T[]; edges: CanvasConnection[] },
id: () => string,
x: number,
y: number
) {
if (
!template ||
!Array.isArray(template.nodes) ||
!template.nodes.length ||
template.nodes.length > 100
)
throw Error("编组模板节点数量无效");
if (
!Array.isArray(template.edges) ||
template.edges.length > 300 ||
new Set(template.nodes.map((n) => n.id)).size !== template.nodes.length ||
template.nodes.some(
(n) =>
!n.id ||
![n.x, n.y, n.width, n.height].every(Number.isFinite) ||
n.width <= 0 ||
n.height <= 0 ||
n.width > 10000 ||
n.height > 10000
)
)
throw Error("编组模板数据无效");
for (const node of template.nodes) {
if (
parentGroup(node) &&
!template.nodes.some(
(n) => n.id === parentGroup(node) && isCanvasGroup(n)
)
)
throw Error("编组模板成员关系无效");
if (isCanvasGroup(node)) groupMembers(template.nodes, node.id);
}
for (const edge of template.edges) {
const error = connectionError(
template.nodes,
template.edges.filter((e) => e !== edge),
edge.source,
edge.target
);
if (error) throw Error(error);
}
const map = new Map(template.nodes.map((n) => [n.id, id()]));
const root = template.nodes.find((n) => isCanvasGroup(n) && !parentGroup(n));
if (!root) throw Error("编组模板缺少根组");
if (
groupMembers(template.nodes, root.id).length !==
template.nodes.length - 1
)
throw Error("模板必须只有一个完整根组");
const nodes = template.nodes.map((n) => ({
...n,
id: map.get(n.id)!,
x: n.x + x - root.x,
y: n.y + y - root.y,
draftValues: {
...n.draftValues,
parentGroupId: map.get(parentGroup(n)) || "",
shotNodeIds: n.draftValues?.shotNodeIds
?.map((key: string) => map.get(key))
.filter(Boolean),
sourceNodeId: map.get(n.draftValues?.sourceNodeId) || undefined,
sourceVideoNodeId: map.get(n.draftValues?.sourceVideoNodeId) || undefined,
},
}));
return {
nodes,
edges: template.edges
.map((e) => ({
...e,
id: id(),
source: map.get(e.source)!,
target: map.get(e.target)!,
}))
.filter((e) => e.source && e.target),
groupId: map.get(root.id)!,
};
}
export function assignGroupMembers<T extends GroupNode>(
nodes: T[],
ids: string[],
groupId: string
) {
if (groupId && !nodes.some((n) => n.id === groupId && isCanvasGroup(n)))
throw Error("编组不存在");
const roots = selectedRoots(nodes, ids);
if (
roots.some(
(n) =>
n.id === groupId ||
(isCanvasGroup(n) &&
groupMembers(nodes, n.id).some((child) => child.id === groupId))
)
)
throw Error("不能将编组加入自身或自己的子组");
const selected = new Set(roots.map((n) => n.id));
return moveCanvasSelection(
nodes.map((n) =>
selected.has(n.id)
? { ...n, draftValues: { ...n.draftValues, parentGroupId: groupId } }
: n
),
[],
0,
0
);
}

View File

@ -1,12 +1,23 @@
import { hasRecoverableCanvasTask } from "./task-recovery";
import { normalizeVideoAnalysisRows } from "./video-story";
import { storyTaskAttachmentSchema, mergeStoryTaskResult } from "./story-task-attachment";
import {
storyTaskAttachmentSchema,
mergeStoryTaskResult,
} from "./story-task-attachment";
import { z } from "zod";
export const canvasTaskRequestSchema = z.object({
requestId: z.string().uuid(),
projectId: z.string().uuid(),
kind: z.enum(["video", "image", "analysis", "image-operation", "audio", "smart-edit"]),
kind: z.enum([
"video",
"image",
"analysis",
"image-operation",
"audio",
"smart-edit",
"video-upscale",
]),
node: z
.object({
id: z.string().min(1).max(180),
@ -21,6 +32,27 @@ export const canvasTaskRequestSchema = z.object({
})
.passthrough(),
params: z.record(z.unknown()),
workflow: z
.object({
runId: z.string().uuid(),
groupId: z.string().min(1).max(180),
inputs: z
.array(
z.object({
output: z.literal("edit-plan").optional(),
requestId: z.string().uuid().optional(),
node: z.object({
id: z.string().min(1).max(180),
title: z.string().optional(),
kind: z.string().optional(),
inputType: z.string().optional(),
draftValues: z.record(z.any()).optional(),
}),
})
)
.max(30),
})
.optional(),
attachment: storyTaskAttachmentSchema.optional(),
hiddenNodeIds: z.array(z.string()).max(20).optional(),
edges: z
@ -47,9 +79,15 @@ export function canCancelCanvasTask(status: string) {
return status === "pending";
}
export function requireAnalysisResult(data: Record<string, any> | undefined, mode: string) {
export function requireAnalysisResult(
data: Record<string, any> | undefined,
mode: string
) {
if (!data) throw Error("分析没有返回结果,原内容已保留");
if (mode === "video-analysis" && !normalizeVideoAnalysisRows(data.analysisRows).length)
if (
mode === "video-analysis" &&
!normalizeVideoAnalysisRows(data.analysisRows).length
)
throw Error("视频解析没有返回有效镜头,原内容已保留");
if (
mode === "director-reconstruct" &&
@ -66,8 +104,8 @@ export function requireAnalysisResult(data: Record<string, any> | undefined, mod
!rows.length ||
!rows.some((row) =>
[row.sceneDescription, row.description, row.imagePrompt].some(
(value) => typeof value === "string" && value.trim(),
),
(value) => typeof value === "string" && value.trim()
)
))
) {
throw Error("脚本没有返回有效镜头,原内容已保留");
@ -76,7 +114,7 @@ export function requireAnalysisResult(data: Record<string, any> | undefined, mod
mode !== "story-script" &&
mode !== "video-analysis" &&
![data.raw, data.summary, ...(data.agentPlan || [])].some(
(value) => typeof value === "string" && value.trim(),
(value) => typeof value === "string" && value.trim()
)
) {
throw Error("分析返回了空内容,原内容已保留");
@ -87,13 +125,20 @@ export function requireAnalysisResult(data: Record<string, any> | undefined, mod
// Stable result IDs and request IDs prevent repeat polling from duplicating images
// or an old task from overwriting a later generation on the same node.
export function reconcileCanvasTasks<
T extends { id: string; draftValues?: Record<string, any>; [key: string]: any },
T extends {
id: string;
draftValues?: Record<string, any>;
[key: string]: any;
},
>(nodes: T[], tasks: CanvasGenerationTask[], projectRevision: string) {
let result = nodes;
const latestByNode = new Map<string, CanvasGenerationTask>();
for (const task of tasks) {
const previous = latestByNode.get(task.request.node.id);
if (!previous || Date.parse(task.createdAt) >= Date.parse(previous.createdAt))
if (
!previous ||
Date.parse(task.createdAt) >= Date.parse(previous.createdAt)
)
latestByNode.set(task.request.node.id, task);
}
for (const task of latestByNode.values()) {
@ -109,7 +154,9 @@ export function reconcileCanvasTasks<
const node = result[index];
const draft = node.draftValues || {};
if (draft.taskRequestId && draft.taskRequestId !== task.requestId) {
const savedTask = tasks.find((item) => item.requestId === draft.taskRequestId);
const savedTask = tasks.find(
(item) => item.requestId === draft.taskRequestId
);
if (
!savedTask ||
Date.parse(task.createdAt) <= Date.parse(savedTask.createdAt) ||
@ -117,7 +164,7 @@ export function reconcileCanvasTasks<
)
continue;
}
const appliedStatus = `${task.id}:${task.status}${task.request.kind === "smart-edit" ? `:${task.result?.smartEdit?.stage || ""}` : ""}`;
const appliedStatus = `${task.id}:${task.status}${["smart-edit", "video-upscale"].includes(task.request.kind) ? `:${task.result?.upscale?.stage || task.result?.smartEdit?.stage || ""}` : ""}`;
if (draft.appliedTaskStatus === appliedStatus) continue;
const nextDraft = {
...draft,
@ -128,7 +175,11 @@ export function reconcileCanvasTasks<
generationProgress: 0,
generationStatus: "pending",
pendingLabel:
task.status === "pending" ? "排队中" : task.result?.smartEdit?.stage || "生成中",
task.status === "pending"
? "排队中"
: task.result?.upscale?.stage ||
task.result?.smartEdit?.stage ||
"生成中",
};
let subtitle = nextDraft.pendingLabel;
if (task.status === "succeeded") {
@ -144,15 +195,20 @@ export function reconcileCanvasTasks<
if (task.request.params.operation !== "render") {
const saved = draft.smartEditDraft;
const processing = ["separate", "transcribe"].includes(
String(task.request.params.operation),
String(task.request.params.operation)
);
const plans =
processing && saved?.plans?.length
? saved.plans.map((plan: unknown, i: number) =>
i === (saved.selectedPlanIndex || 0) ? data.plans?.[0] || plan : plan,
i === (saved.selectedPlanIndex || 0)
? data.plans?.[0] || plan
: plan
)
: data.plans;
Object.assign(nextDraft, { smartEditPlans: plans, smartEditPlanTaskId: task.id });
Object.assign(nextDraft, {
smartEditPlans: plans,
smartEditPlanTaskId: task.id,
});
if (processing && saved)
Object.assign(nextDraft, {
smartEditDraft: { ...saved, plans, planTaskId: task.id },
@ -176,22 +232,29 @@ export function reconcileCanvasTasks<
action: "AUDIO_RESOURCE",
mediaType: "audio",
audioMeta: data.metadata,
audioParams: task.request.params,
audioParams:
task.request.node.draftValues?.audioParams || task.request.params,
});
if (task.request.kind === "video")
if (["video", "video-upscale"].includes(task.request.kind))
Object.assign(nextDraft, {
mediaUrl: data.url,
url: [data.url],
videoMeta: data.metadata,
});
if (task.request.kind === "image" || task.request.kind === "image-operation") {
if (
task.request.kind === "image" ||
task.request.kind === "image-operation"
) {
const urls = (data.images || [])
.map((image: any) => image.originalUrl || image.thumbnailUrl)
.filter(Boolean);
Object.assign(nextDraft, {
mediaUrl: urls[0],
url: urls,
action: draft.action === "IMAGE_GENERATE_NINE_GRID" ? draft.action : "IMAGE_RESOURCE",
action:
draft.action === "IMAGE_GENERATE_NINE_GRID"
? draft.action
: "IMAGE_RESOURCE",
});
for (let i = 1; i < urls.length; i++) {
const id = `${source.id}-result-${task.id}-${i}`;
@ -203,7 +266,11 @@ export function reconcileCanvasTasks<
id,
title: `${node.title} ${i + 1}`,
y: node.y + i * (node.height + 40),
draftValues: { ...nextDraft, mediaUrl: urls[i], url: [urls[i]] },
draftValues: {
...nextDraft,
mediaUrl: urls[i],
url: [urls[i]],
},
} as T,
];
}
@ -218,7 +285,9 @@ export function reconcileCanvasTasks<
});
if (task.request.kind === "analysis")
Object.assign(nextDraft, {
content: [data.raw || data.summary || data.agentPlan?.join("\n") || ""],
content: [
data.raw || data.summary || data.agentPlan?.join("\n") || "",
],
...(Array.isArray(data.analysisRows || data.shotIdeas)
? { rows: data.analysisRows || data.shotIdeas }
: {}),
@ -235,7 +304,8 @@ export function reconcileCanvasTasks<
: "任务失败,请查看任务状态"),
});
if (task.status === "interrupted" && hasRecoverableCanvasTask(task))
nextDraft.generationError = "任务查询中断,稍后会自动查询原任务,请勿重复生成。";
nextDraft.generationError =
"任务查询中断,稍后会自动查询原任务,请勿重复生成。";
subtitle =
task.status === "cancelled"
? "已取消"
@ -245,7 +315,10 @@ export function reconcileCanvasTasks<
: "待核实"
: "生成失败";
}
if (task.request.kind === "analysis" && task.request.params.mode === "video-analysis") {
if (
task.request.kind === "analysis" &&
task.request.params.mode === "video-analysis"
) {
Object.assign(nextDraft, {
status:
task.status === "succeeded"
@ -260,7 +333,7 @@ export function reconcileCanvasTasks<
rows: normalizeVideoAnalysisRows(
task.result?.analysisRows,
task.result?.framePreviews || [],
source.id,
source.id
),
framePreviews: task.result?.framePreviews || [],
summary: task.result?.summary || "",
@ -273,11 +346,15 @@ export function reconcileCanvasTasks<
...item,
...(task.status === "succeeded" &&
task.request.kind !== "analysis" &&
!(task.request.kind === "smart-edit" && task.request.params.operation !== "render")
!(
task.request.kind === "smart-edit" &&
task.request.params.operation !== "render"
)
? {
kind: "media",
inputType:
task.request.kind === "video" || task.request.kind === "smart-edit"
["video", "video-upscale"].includes(task.request.kind) ||
task.request.kind === "smart-edit"
? "video"
: task.request.kind === "audio"
? "audio"
@ -287,7 +364,7 @@ export function reconcileCanvasTasks<
subtitle,
draftValues: nextDraft,
}
: item,
: item
);
}
for (const task of tasks) {
@ -299,14 +376,21 @@ export function reconcileCanvasTasks<
!result.some((node) => node.id === task.request.node.id)
)
continue;
const targetIndex = result.findIndex((node) => node.id === attachment.nodeId);
const targetIndex = result.findIndex(
(node) => node.id === attachment.nodeId
);
if (targetIndex < 0) continue;
const node = result[targetIndex],
draft = node.draftValues || {};
const next = mergeStoryTaskResult(draft, attachment, task.result || {}, task.id);
const next = mergeStoryTaskResult(
draft,
attachment,
task.result || {},
task.id
);
if (next !== draft)
result = result.map((item, index) =>
index === targetIndex ? { ...item, draftValues: next } : item,
index === targetIndex ? { ...item, draftValues: next } : item
);
}
return result;

View File

@ -10,7 +10,9 @@ export type RecoveryTask = {
request: { kind: string; params: Record<string, unknown> };
result?: Record<string, any> | null;
};
export function getEnhanceCheckpoint(task: Pick<RecoveryTask, "request" | "result">) {
export function getEnhanceCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
const upstream = task.result?.upstreamTask;
if (
task.request.kind !== "image-operation" ||
@ -27,7 +29,9 @@ export function getEnhanceCheckpoint(task: Pick<RecoveryTask, "request" | "resul
sourceImageUrl: String(upstream.sourceImageUrl),
};
}
export function getVideoCheckpoint(task: Pick<RecoveryTask, "request" | "result">) {
export function getVideoCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
if (task.request.kind !== "video") return null;
const parsed = videoCheckpointSchema.safeParse(task.result?.upstreamTask);
if (!parsed.success) return null;
@ -41,7 +45,9 @@ export function getVideoCheckpoint(task: Pick<RecoveryTask, "request" | "result"
return null;
return parsed.data;
}
export function getAudioCheckpoint(task: Pick<RecoveryTask, "request" | "result">) {
export function getAudioCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
if (task.request.kind !== "audio") return null;
const parsed = audioCheckpointSchema.safeParse(task.result?.upstreamAudio);
return parsed.success && parsed.data.modelRef === task.request.params.modelRef
@ -49,7 +55,7 @@ export function getAudioCheckpoint(task: Pick<RecoveryTask, "request" | "result"
: null;
}
export function getSmartEditCheckpoint(
task: Pick<RecoveryTask, "request" | "result">,
task: Pick<RecoveryTask, "request" | "result">
): SmartEditCheckpoint | null {
const value = task.result?.smartEdit;
if (
@ -75,7 +81,8 @@ export function hasRecoverableCanvasTask(task: RecoveryTask, now = Date.now()) {
getEnhanceCheckpoint(task) ||
getVideoCheckpoint(task) ||
getAudioCheckpoint(task) ||
getSmartEditCheckpoint(task),
getSmartEditCheckpoint(task) ||
getUpscaleCheckpoint(task)
) &&
Number.isFinite(created) &&
now >= created &&
@ -84,8 +91,23 @@ export function hasRecoverableCanvasTask(task: RecoveryTask, now = Date.now()) {
);
}
export function canResumeCanvasTask(task: RecoveryTask, now = Date.now()) {
if (task.status !== "interrupted" || !hasRecoverableCanvasTask(task, now)) return false;
if (task.status !== "interrupted" || !hasRecoverableCanvasTask(task, now))
return false;
const updated = Date.parse(task.updatedAt || "");
const delay = Math.min(300000, 30000 * 2 ** Math.min(4, Math.max(0, (task.attempts || 1) - 1)));
const delay = Math.min(
300000,
30000 * 2 ** Math.min(4, Math.max(0, (task.attempts || 1) - 1))
);
return Number.isFinite(updated) && now - updated >= delay;
}
export function getUpscaleCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
const value = task.result?.upscale?.remote;
return task.request.kind === "video-upscale" &&
/^[a-f0-9]{64}$/.test(value?.id || "") &&
/^[a-f0-9]{64}$/.test(value?.endpointHash || "")
? (value as { id: string; endpointHash: string })
: null;
}

View File

@ -1,16 +1,20 @@
import { validateGroupBatch, groupDependencyState } from "./group-execution";
import { canResumeCanvasTask, hasRecoverableCanvasTask } from "./task-recovery";
import "server-only";
import { and, asc, desc, eq, gte, lt, sql } from "drizzle-orm";
import { and, asc, desc, eq, gte, lt, sql, inArray } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasCapabilityTask as table, canvasProject } from "@/lib/db/schema";
import { type CanvasTaskRequest, type CanvasGenerationTask } from "./task-contract";
import {
type CanvasTaskRequest,
type CanvasGenerationTask,
} from "./task-contract";
const capabilityId = "video-canvas.generation";
const scope = (userId: string, projectId: string) =>
and(
eq(table.userId, userId),
eq(table.projectId, projectId),
eq(table.capabilityId, capabilityId),
eq(table.capabilityId, capabilityId)
);
const serialize = (row: typeof table.$inferSelect): CanvasGenerationTask => ({
id: row.id,
@ -29,26 +33,37 @@ export async function ownsTaskProject(userId: string, projectId: string) {
await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId)))
.where(
and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))
)
.limit(1)
)[0],
)[0]
);
}
export async function createCanvasGenerationTask(userId: string, request: CanvasTaskRequest) {
export async function createCanvasGenerationTask(
userId: string,
request: CanvasTaskRequest
) {
return db.transaction(async (tx) => {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${request.projectId}))`,
sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${request.projectId}))`
);
const existing = (
await tx
.select()
.from(table)
.where(and(scope(userId, request.projectId), eq(table.requestId, request.requestId)))
.where(
and(
scope(userId, request.projectId),
eq(table.requestId, request.requestId)
)
)
.limit(1)
)[0];
if (existing) {
if (
JSON.stringify(existing.request) !== JSON.stringify(JSON.parse(JSON.stringify(request)))
JSON.stringify(existing.request) !==
JSON.stringify(JSON.parse(JSON.stringify(request)))
) {
// JSONB property order is not stable. Compare the immutable logical request.
const old = existing.request as CanvasTaskRequest;
@ -57,33 +72,46 @@ export async function createCanvasGenerationTask(userId: string, request: Canvas
old.kind !== request.kind ||
JSON.stringify(sortObject(old.attachment)) !==
JSON.stringify(sortObject(request.attachment)) ||
JSON.stringify(sortObject(old.params)) !== JSON.stringify(sortObject(request.params))
JSON.stringify(sortObject(old.params)) !==
JSON.stringify(sortObject(request.params))
)
throw Object.assign(Error("同一任务 ID 不能用于不同请求"), { status: 409 });
throw Object.assign(Error("同一任务 ID 不能用于不同请求"), {
status: 409,
});
}
return serialize(existing);
}
const active = await tx
.select()
.from(table)
.where(and(scope(userId, request.projectId), sql`${table.status} in ('pending','running')`));
.where(
and(
scope(userId, request.projectId),
sql`${table.status} in ('pending','running')`
)
);
const duplicate = active.find(
(row) => (row.request as CanvasTaskRequest).node.id === request.node.id,
(row) => (row.request as CanvasTaskRequest).node.id === request.node.id
);
if (duplicate) return serialize(duplicate);
const interrupted = await tx
.select()
.from(table)
.where(and(scope(userId, request.projectId), eq(table.status, "interrupted")));
.where(
and(scope(userId, request.projectId), eq(table.status, "interrupted"))
);
const recovering = interrupted.find(
(row) =>
(row.request as CanvasTaskRequest).node.id === request.node.id &&
hasRecoverableCanvasTask(serialize(row)),
hasRecoverableCanvasTask(serialize(row))
);
if (recovering) return serialize(recovering);
if (active.length >= 40)
throw Object.assign(Error("当前画布排队任务已达 40 个,请等待完成后继续"), { status: 429 });
throw Object.assign(
Error("当前画布排队任务已达 40 个,请等待完成后继续"),
{ status: 429 }
);
const [created] = await tx
.insert(table)
.values({
@ -107,11 +135,14 @@ function sortObject(value: any): any {
? Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, sortObject(value[key])]),
.map((key) => [key, sortObject(value[key])])
)
: value;
}
export async function listCanvasGenerationTasks(userId: string, projectId: string) {
export async function listCanvasGenerationTasks(
userId: string,
projectId: string
) {
// A worker lost to process shutdown must never silently re-submit a paid request.
await db
.update(table)
@ -125,8 +156,8 @@ export async function listCanvasGenerationTasks(userId: string, projectId: strin
and(
scope(userId, projectId),
eq(table.status, "running"),
lt(table.updatedAt, new Date(Date.now() - 120000)),
),
lt(table.updatedAt, new Date(Date.now() - 120000))
)
);
const rows = await db
.select()
@ -136,10 +167,13 @@ export async function listCanvasGenerationTasks(userId: string, projectId: strin
.limit(240);
return rows.map(serialize).reverse();
}
export async function claimCanvasGenerationTask(userId: string, projectId: string) {
export async function claimCanvasGenerationTask(
userId: string,
projectId: string
) {
return db.transaction(async (tx) => {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${projectId}))`,
sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${projectId}))`
);
if (
(
@ -151,12 +185,57 @@ export async function claimCanvasGenerationTask(userId: string, projectId: strin
).length
)
return null;
let [next] = await tx
const pending = await tx
.select()
.from(table)
.where(and(scope(userId, projectId), eq(table.status, "pending")))
.orderBy(asc(table.createdAt))
.limit(1);
.limit(40);
const dependencyIds = [
...new Set(
pending.flatMap(
(row) =>
(row.request as CanvasTaskRequest).workflow?.inputs
.map((input) => input.requestId)
.filter((id): id is string => Boolean(id)) || []
)
),
];
const predecessors = dependencyIds.length
? await tx
.select()
.from(table)
.where(
and(
scope(userId, projectId),
inArray(table.requestId, dependencyIds)
)
)
: [];
const tasks = [
...new Map(
[...pending, ...predecessors].map((row) => [row.id, serialize(row)])
).values(),
];
let next: typeof table.$inferSelect | undefined;
for (const candidate of pending) {
const state = groupDependencyState(
candidate.request as CanvasTaskRequest,
tasks
);
if (state === "failed") {
await tx
.update(table)
.set({
status: "failed",
error: "上游任务失败或取消,未提交此节点",
updatedAt: new Date(),
completedAt: new Date(),
})
.where(eq(table.id, candidate.id));
tasks.find((t) => t.id === candidate.id)!.status = "failed";
} else if (state === "ready" && !next) next = candidate;
}
if (!next) {
const interrupted = await tx
.select()
@ -166,8 +245,8 @@ export async function claimCanvasGenerationTask(userId: string, projectId: strin
scope(userId, projectId),
eq(table.status, "interrupted"),
gte(table.createdAt, new Date(Date.now() - 86400000)),
lt(table.attempts, 20),
),
lt(table.attempts, 20)
)
)
.orderBy(asc(table.updatedAt));
next = interrupted.find((row) => canResumeCanvasTask(serialize(row)))!;
@ -187,7 +266,10 @@ export async function claimCanvasGenerationTask(userId: string, projectId: strin
return claimed ? serialize(claimed) : null;
});
}
export async function heartbeatCanvasGenerationTask(id: string, attempt?: number) {
export async function heartbeatCanvasGenerationTask(
id: string,
attempt?: number
) {
await db
.update(table)
.set({ updatedAt: new Date() })
@ -195,15 +277,15 @@ export async function heartbeatCanvasGenerationTask(id: string, attempt?: number
and(
eq(table.id, id),
eq(table.status, "running"),
attempt === undefined ? undefined : eq(table.attempts, attempt),
),
attempt === undefined ? undefined : eq(table.attempts, attempt)
)
);
}
export async function finishCanvasGenerationTask(
id: string,
result?: Record<string, any>,
error?: string,
attempt?: number,
attempt?: number
) {
// A late real result can recover an interrupted task, but cannot replace a newer request ID.
await db
@ -219,11 +301,15 @@ export async function finishCanvasGenerationTask(
and(
eq(table.id, id),
sql`${table.status} in ('running','interrupted')`,
attempt === undefined ? undefined : eq(table.attempts, attempt),
),
attempt === undefined ? undefined : eq(table.attempts, attempt)
)
);
}
export async function cancelCanvasGenerationTask(userId: string, projectId: string, id: string) {
export async function cancelCanvasGenerationTask(
userId: string,
projectId: string,
id: string
) {
const [cancelled] = await db
.update(table)
.set({
@ -232,7 +318,13 @@ export async function cancelCanvasGenerationTask(userId: string, projectId: stri
updatedAt: new Date(),
completedAt: new Date(),
})
.where(and(scope(userId, projectId), eq(table.id, id), eq(table.status, "pending")))
.where(
and(
scope(userId, projectId),
eq(table.id, id),
eq(table.status, "pending")
)
)
.returning();
if (cancelled) return { ok: true, task: serialize(cancelled) };
const [current] = await db
@ -240,13 +332,16 @@ export async function cancelCanvasGenerationTask(userId: string, projectId: stri
.from(table)
.where(and(scope(userId, projectId), eq(table.id, id)))
.limit(1);
return { ok: current?.status === "cancelled", task: current ? serialize(current) : null };
return {
ok: current?.status === "cancelled",
task: current ? serialize(current) : null,
};
}
export async function hideCanvasGenerationNode(
userId: string,
projectId: string,
id: string,
nodeId: string,
nodeId: string
) {
return db.transaction(async (tx) => {
const [task] = await tx
@ -255,15 +350,22 @@ export async function hideCanvasGenerationNode(
.where(and(scope(userId, projectId), eq(table.id, id)))
.for("update");
if (!task) return false;
const request = task.request as CanvasTaskRequest & { hiddenNodeIds?: string[] };
if (nodeId !== request.node.id && !nodeId.startsWith(`${request.node.id}-result-`))
const request = task.request as CanvasTaskRequest & {
hiddenNodeIds?: string[];
};
if (
nodeId !== request.node.id &&
!nodeId.startsWith(`${request.node.id}-result-`)
)
return false;
await tx
.update(table)
.set({
request: {
...request,
hiddenNodeIds: [...new Set([...(request.hiddenNodeIds || []), nodeId])],
hiddenNodeIds: [
...new Set([...(request.hiddenNodeIds || []), nodeId]),
],
},
})
.where(eq(table.id, id));
@ -274,7 +376,7 @@ export async function hideCanvasGenerationNode(
export async function checkpointCanvasGenerationTask(
id: string,
result: Record<string, unknown>,
attempt?: number,
attempt?: number
) {
await db
.update(table)
@ -283,11 +385,15 @@ export async function checkpointCanvasGenerationTask(
and(
eq(table.id, id),
eq(table.status, "running"),
attempt === undefined ? undefined : eq(table.attempts, attempt),
),
attempt === undefined ? undefined : eq(table.attempts, attempt)
)
);
}
export async function interruptCanvasGenerationTask(id: string, error: string, attempt?: number) {
export async function interruptCanvasGenerationTask(
id: string,
error: string,
attempt?: number
) {
await db
.update(table)
.set({ status: "interrupted", error, updatedAt: new Date() })
@ -295,8 +401,8 @@ export async function interruptCanvasGenerationTask(id: string, error: string, a
and(
eq(table.id, id),
eq(table.status, "running"),
attempt === undefined ? undefined : eq(table.attempts, attempt),
),
attempt === undefined ? undefined : eq(table.attempts, attempt)
)
);
}
@ -304,7 +410,114 @@ export async function ownsCanvasTaskLease(id: string, attempt: number) {
const [row] = await db
.select({ id: table.id })
.from(table)
.where(and(eq(table.id, id), eq(table.attempts, attempt), eq(table.status, "running")))
.where(
and(
eq(table.id, id),
eq(table.attempts, attempt),
eq(table.status, "running")
)
)
.limit(1);
return Boolean(row);
}
export async function createCanvasGroupTasks(
userId: string,
requests: CanvasTaskRequest[]
) {
validateGroupBatch(requests);
return db.transaction(async (tx) => {
const projectId = requests[0].projectId;
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${projectId}))`
);
const same = await tx
.select()
.from(table)
.where(
and(
scope(userId, projectId),
sql`${table.request}->'workflow'->>'runId' = ${requests[0].workflow!.runId}`
)
);
if (same.length) {
if (
same.length !== requests.length ||
requests.some((r) => {
const old = same.find((t) => t.requestId === r.requestId);
return (
!old ||
JSON.stringify(sortObject(old.request)) !==
JSON.stringify(sortObject(r))
);
})
)
throw Object.assign(Error("同一编组执行编号不能用于不同参数"), {
status: 409,
});
return requests.map((request) =>
serialize(same.find((row) => row.requestId === request.requestId)!)
);
}
const candidates = await tx
.select()
.from(table)
.where(
and(
scope(userId, projectId),
sql`(${table.status} in ('pending','running') or (${table.status}='interrupted' and ${table.createdAt} > now() - interval '24 hours'))`
)
);
const active = candidates.filter(
(t) =>
["pending", "running"].includes(t.status) ||
(t.status === "interrupted" && hasRecoverableCanvasTask(serialize(t)))
);
if (active.length + requests.length > 40)
throw Object.assign(Error("画布任务队列已满"), { status: 429 });
if (
active.some((t) =>
requests.some(
(r) => r.node.id === (t.request as CanvasTaskRequest).node.id
)
)
)
throw Object.assign(Error("编组中已有节点正在执行,请等待完成"), {
status: 409,
});
const now = Date.now();
const rows = await tx
.insert(table)
.values(
requests.map((request, i) => ({
requestId: request.requestId,
userId,
projectId,
capabilityId,
source: "video-canvas",
request,
createdAt: new Date(now + i),
updatedAt: new Date(now),
}))
)
.returning();
return rows.map(serialize);
});
}
export async function groupTaskInputs(
userId: string,
request: CanvasTaskRequest
) {
const rows = await db
.select()
.from(table)
.where(
and(
scope(userId, request.projectId),
sql`${table.request}->'workflow'->>'runId' = ${request.workflow?.runId || ""}`
)
);
return rows
.map(serialize)
.filter((t) => t.request.workflow?.runId === request.workflow?.runId);
}

View File

@ -1,3 +1,7 @@
import { resolveGroupInputs } from "./group-execution";
import { groupTaskInputs } from "./task-store";
import { executeVideoUpscale } from "./video-upscale";
import { getUpscaleCheckpoint } from "./task-recovery";
import { executeSmartEdit } from "./smart-edit/execute";
import { executeCanvasAudio } from "./audio-generation";
import { ProviderTaskUncertainError } from "./video-task-lifecycle";
@ -13,7 +17,10 @@ import {
ImageOperationUncertainError,
CanvasTaskLeaseLostError,
} from "./image-operation";
import { executeEnhancePOST, executeEnhanceGET } from "@/lib/canvas/operations/enhance";
import {
executeEnhancePOST,
executeEnhanceGET,
} from "@/lib/canvas/operations/enhance";
import { executeExtendPOST } from "@/lib/canvas/operations/extend";
import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit";
@ -26,7 +33,10 @@ import "server-only";
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response";
import { executeWorkflowAnalysisResponse } from "./workflow-analysis-response";
import { requireAnalysisResult, type CanvasGenerationTask } from "./task-contract";
import {
requireAnalysisResult,
type CanvasGenerationTask,
} from "./task-contract";
import { validateVideoReferences } from "./reference-input";
import { probeRemoteUrl } from "@/lib/net/url-access";
import {
@ -46,21 +56,40 @@ async function execute(task: CanvasGenerationTask, userId: string) {
const videoResume = getVideoCheckpoint(task);
const audioResume = getAudioCheckpoint(task);
const editResume = getSmartEditCheckpoint(task);
if ((task.attempts || 1) > 1 && !resume && !videoResume && !audioResume && !editResume)
const upscaleResume = getUpscaleCheckpoint(task);
if (
(task.attempts || 1) > 1 &&
!resume &&
!videoResume &&
!audioResume &&
!editResume &&
!upscaleResume
)
throw Error("缺少上游任务编号,禁止重新提交");
const params = request.params;
const params = request.workflow
? resolveGroupInputs(request, await groupTaskInputs(userId, request))
: request.params;
if (request.kind === "video-upscale")
return executeVideoUpscale(params, userId, task.id, {
resume: upscaleResume || undefined,
active: assertLease,
save: (value) =>
checkpointCanvasGenerationTask(task.id, value, task.attempts),
});
if (request.kind === "smart-edit")
return executeSmartEdit(params, userId, {
taskId: task.id,
resume: editResume,
assertActive: assertLease,
checkpoint: (value) => checkpointCanvasGenerationTask(task.id, value, task.attempts),
checkpoint: (value) =>
checkpointCanvasGenerationTask(task.id, value, task.attempts),
});
if (request.kind === "audio")
return executeCanvasAudio(params, {
resume: audioResume,
assertActive: assertLease,
checkpoint: (value) => checkpointCanvasGenerationTask(task.id, value, task.attempts),
checkpoint: (value) =>
checkpointCanvasGenerationTask(task.id, value, task.attempts),
});
if (request.kind === "image-operation")
return executeImageOperation(
@ -76,14 +105,15 @@ async function execute(task: CanvasGenerationTask, userId: string) {
{
resume,
assertLease,
checkpoint: (result) => checkpointCanvasGenerationTask(task.id, result, task.attempts),
},
checkpoint: (result) =>
checkpointCanvasGenerationTask(task.id, result, task.attempts),
}
);
if (request.kind === "video") {
const checked = videoResume
? { images: params.images as string[] | undefined, error: undefined }
: await validateVideoReferences(params.images, (url) =>
probeRemoteUrl(url, { timeoutMs: 8000 }),
probeRemoteUrl(url, { timeoutMs: 8000 })
);
if (checked.error) throw Error(checked.error);
const result = await executeDirectVideoGeneration({
@ -95,7 +125,11 @@ async function execute(task: CanvasGenerationTask, userId: string) {
resume: videoResume,
assertActive: assertLease,
save: (checkpoint) =>
checkpointCanvasGenerationTask(task.id, { upstreamTask: checkpoint }, task.attempts),
checkpointCanvasGenerationTask(
task.id,
{ upstreamTask: checkpoint },
task.attempts
),
},
});
if (!result.success || !result.videoUrl) throw Error("视频生成未返回结果");
@ -105,7 +139,9 @@ async function execute(task: CanvasGenerationTask, userId: string) {
...result.metadata,
temporaryVideoUrl: result.videoUrl,
storageStatus:
result.metadata?.permanentVideoUrl === result.videoUrl ? "archived" : "temporary",
result.metadata?.permanentVideoUrl === result.videoUrl
? "archived"
: "temporary",
},
modelId: result.modelId,
modelRef: result.modelRef,
@ -115,35 +151,44 @@ async function execute(task: CanvasGenerationTask, userId: string) {
request.kind === "image"
? await executeCanvasImageResponse(
{ ...params, prompt: String(params.prompt || ""), async: false },
userId,
userId
)
: await executeWorkflowAnalysisResponse(params);
const body = await response.json();
if (!response.ok || !body.success) throw Error(body.error || "任务执行失败");
if (
request.kind === "image" &&
!body.data?.images?.some((image: any) => image.originalUrl || image.thumbnailUrl)
!body.data?.images?.some(
(image: any) => image.originalUrl || image.thumbnailUrl
)
)
throw Error("图片生成没有返回结果");
if (request.kind === "analysis") requireAnalysisResult(body.data, String(params.mode));
if (request.kind === "analysis")
requireAnalysisResult(body.data, String(params.mode));
requireStoryPromptResult(body.data, request.attachment);
return body.data;
}
export async function runCanvasGenerationQueue(userId: string, projectId: string) {
export async function runCanvasGenerationQueue(
userId: string,
projectId: string
) {
// Work is independent of the browser request. Atomic DB claiming also covers multiple processes.
for (let i = 0; i < 40; i++) {
const task = await claimCanvasGenerationTask(userId, projectId);
if (!task) return;
const timer = setInterval(
() => void heartbeatCanvasGenerationTask(task.id, task.attempts).catch(() => {}),
20000,
() =>
void heartbeatCanvasGenerationTask(task.id, task.attempts).catch(
() => {}
),
20000
);
try {
await finishCanvasGenerationTask(
task.id,
await execute(task, userId),
undefined,
task.attempts,
task.attempts
);
} catch (error) {
if (error instanceof CanvasTaskLeaseLostError) return;
@ -151,14 +196,18 @@ export async function runCanvasGenerationQueue(userId: string, projectId: string
error instanceof ImageOperationUncertainError ||
error instanceof ProviderTaskUncertainError
) {
await interruptCanvasGenerationTask(task.id, error.message, task.attempts);
await interruptCanvasGenerationTask(
task.id,
error.message,
task.attempts
);
continue;
}
await finishCanvasGenerationTask(
task.id,
undefined,
error instanceof Error ? error.message : "任务执行失败",
task.attempts,
task.attempts
);
} finally {
clearInterval(timer);

View File

@ -0,0 +1,33 @@
import { z } from "zod";
export const videoUpscaleSchema = z.object({
url: z
.string()
.url()
.refine((v) => /^https?:\/\//.test(v)),
resolution: z.enum(["1080P", "2K", "4K"]),
});
export type VideoUpscaleInput = z.infer<typeof videoUpscaleSchema>;
export function upscaleDimensions(
width: number,
height: number,
resolution: VideoUpscaleInput["resolution"]
) {
if (![width, height].every((v) => Number.isInteger(v) && v > 0))
throw Error("视频尺寸无效");
const [long, short] =
resolution === "4K"
? [3840, 2160]
: resolution === "2K"
? [2560, 1440]
: [1920, 1080];
const ratio = Math.min(
(width >= height ? long : short) / width,
(width >= height ? short : long) / height
);
if (ratio <= 1) throw Error("源视频已达到所选分辨率,请选择更高分辨率");
if (ratio > 4) throw Error("单次 AI 超分最大 4 倍,请先选择较低目标分辨率");
return {
width: Math.floor((width * ratio) / 2) * 2,
height: Math.floor((height * ratio) / 2) * 2,
};
}

View File

@ -0,0 +1,181 @@
import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import path from "node:path";
import { z } from "zod";
import type { VideoUpscaleInput } from "./video-upscale-contract";
import { ProviderTaskUncertainError } from "./video-task-lifecycle";
export type UpscaleCheckpoint = { id: string; endpointHash: string };
const sha = (value: string) => createHash("sha256").update(value).digest("hex");
export function upscaleConfig() {
const value = process.env.VIDEO_UPSCALE_URL;
if (!value) return null;
const url = new URL(value);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
)
throw Error("视频超分服务地址配置无效");
const base = value.replace(/\/$/, ""),
token = process.env.VIDEO_UPSCALE_TOKEN;
if (!token || token.length < 32) throw Error("视频超分服务认证未配置");
return { base, token };
}
const statusSchema = z.object({
id: z.string().regex(/^[a-f0-9]{64}$/),
status: z.enum(["queued", "running", "succeeded", "failed"]),
stage: z.string().max(300),
error: z.string().max(500).optional(),
result: z
.object({
width: z.number().int().positive().max(3840),
height: z.number().int().positive().max(3840),
durationSeconds: z.number().positive().max(120.2),
fps: z.number().positive().max(60),
frames: z.number().int().positive().max(7200),
audio: z.boolean(),
})
.optional(),
});
export async function remoteUpscale(
plan: VideoUpscaleInput,
userId: string,
taskId: string,
dir: string,
lifecycle: {
resume?: UpscaleCheckpoint;
active: () => Promise<void>;
save: (checkpoint: UpscaleCheckpoint, stage: string) => Promise<void>;
}
) {
const config = upscaleConfig();
if (!config)
throw Error(
lifecycle.resume
? "远程超分任务的服务配置已移除,不能回退本机重复超分"
: "视频高清服务尚未接通"
);
const checkpoint = lifecycle.resume || {
id: sha("video-upscale-v1:" + userId + ":" + taskId),
endpointHash: sha(config.base),
};
if (
!/^[a-f0-9]{64}$/.test(checkpoint.id) ||
checkpoint.endpointHash !== sha(config.base)
)
throw Error("超分任务所属服务器已变化,请恢复原服务配置后继续");
await lifecycle.save(checkpoint, "提交算力服务器");
const headers = {
authorization: "Bearer " + config.token,
"content-type": "application/json",
};
async function request(route: string, body?: unknown) {
await lifecycle.active();
let response: Response;
try {
response = await fetch(config!.base + route, {
method: body ? "POST" : "GET",
headers,
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(20000),
redirect: "error",
cache: "no-store",
});
} catch {
throw new ProviderTaskUncertainError(
"算力服务器连接中断,任务已保存,可继续查询"
);
}
if ([429, 500, 502, 503, 504].includes(response.status))
throw new ProviderTaskUncertainError(
"算力服务器繁忙,任务已保存等待继续"
);
if (!response.ok)
throw Error(
`超分服务请求失败(${response.status}),请检查服务状态或任务参数`
);
let data: unknown;
try {
data = await response.json();
} catch {
throw new ProviderTaskUncertainError("超分服务响应中断,将查询同一任务");
}
const result = statusSchema.parse(data);
if (result.id !== checkpoint.id)
throw Error("超分服务返回了不匹配的任务编号");
return result;
}
let status = await request("/v1/upscales", {
id: checkpoint.id,
tenant: sha(userId),
plan,
});
const deadline = Date.now() + 8 * 60000;
while (status.status === "queued" || status.status === "running") {
await lifecycle.save(checkpoint, status.stage);
if (Date.now() > deadline)
throw new ProviderTaskUncertainError("远程超分仍在运行,已保存任务进度");
await new Promise((resolve) => setTimeout(resolve, 3000));
status = await request("/v1/upscales/" + checkpoint.id);
}
if (status.status === "failed")
throw Error(status.error || "算力服务器超分失败");
if (!status.result) throw Error("超分服务未返回成片参数");
await lifecycle.save(checkpoint, "读取远程成片");
async function download(kind: string, destination: string, maxBytes: number) {
await lifecycle.active();
try {
const response = await fetch(
config!.base + "/v1/upscales/" + checkpoint.id + "/" + kind,
{
headers,
signal: AbortSignal.timeout(180000),
redirect: "error",
cache: "no-store",
}
);
if (
!response.ok ||
!response.body ||
Number(response.headers.get("content-length")) > maxBytes
)
throw Error("远程成片不可读取或超过处理上限");
let size = 0;
const limit = new Transform({
transform(chunk, _encoding, callback) {
size += chunk.length;
callback(
size > maxBytes ? Error("远程成片超过处理上限") : null,
chunk
);
},
});
await pipeline(
Readable.fromWeb(response.body as any),
limit,
createWriteStream(destination, { mode: 0o600 })
);
} catch {
throw new ProviderTaskUncertainError(
"成片已生成但读取中断,将继续读取同一任务"
);
}
}
const file = path.join(dir, "result.mp4");
await download("video", file, 300 * 1024 * 1024);
return {
file,
width: status.result.width,
height: status.result.height,
duration: status.result.durationSeconds,
fps: status.result.fps,
frames: status.result.frames,
audio: status.result.audio,
};
}

View File

@ -0,0 +1,62 @@
import { ProviderTaskUncertainError } from "./video-task-lifecycle";
import "server-only";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { uploadFile } from "@/lib/storage/service";
import { videoUpscaleSchema } from "./video-upscale-contract";
import { remoteUpscale, type UpscaleCheckpoint } from "./video-upscale-remote";
export async function executeVideoUpscale(
params: Record<string, unknown>,
userId: string,
taskId: string,
lifecycle: {
resume?: UpscaleCheckpoint;
active: () => Promise<void>;
save: (value: Record<string, unknown>) => Promise<void>;
}
) {
const input = videoUpscaleSchema.parse(params),
dir = await mkdtemp(path.join(tmpdir(), "upscale-"));
try {
const output = await remoteUpscale(input, userId, taskId, dir, {
resume: lifecycle.resume,
active: lifecycle.active,
save: (remote, stage) => lifecycle.save({ upscale: { remote, stage } }),
});
await lifecycle.active();
const file = await readFile(output.file);
let stored;
try {
stored = await uploadFile(
`video-upscale-${taskId}.mp4`,
(file.byteOffset === 0 && file.byteLength === file.buffer.byteLength
? file.buffer
: file.buffer.slice(
file.byteOffset,
file.byteOffset + file.byteLength
)) as ArrayBuffer
);
} catch {
throw new ProviderTaskUncertainError(
"高清结果已生成,归档暂时失败,将继续归档同一结果"
);
}
return {
url: stored.url,
metadata: {
width: output.width,
height: output.height,
durationSeconds: output.duration,
fps: output.fps,
frames: output.frames,
hasAudio: output.audio,
engine: "Real-ESRGAN",
model: "realesr-general-x4v3",
resolution: input.resolution,
},
};
} finally {
await rm(dir, { recursive: true, force: true });
}
}

View File

@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
testMatch: [
/routes\/video-canvas-groups-upscale\.test\.ts/,
/routes\/video-canvas-connections\.test\.ts/,
/routes\/video-canvas-smart-edit\.test\.ts/,
/routes\/video-canvas-tasks\.test\.ts/,

View File

@ -0,0 +1,103 @@
// Real authenticated GPU API smoke test. Inputs must be the documented synthetic one-second clips.
const fs = require("node:fs"),
crypto = require("node:crypto"),
assert = require("node:assert/strict");
const { execFileSync } = require("node:child_process");
const base = process.env.VIDEO_UPSCALE_QA_URL,
token = process.env.VIDEO_UPSCALE_QA_TOKEN,
source = process.env.VIDEO_UPSCALE_QA_SOURCE;
if (!base || !token || !source)
throw Error(
"Set VIDEO_UPSCALE_QA_URL, VIDEO_UPSCALE_QA_TOKEN and VIDEO_UPSCALE_QA_SOURCE"
);
const sha = (s) => crypto.createHash("sha256").update(s).digest("hex"),
headers = {
authorization: "Bearer " + token,
"content-type": "application/json",
};
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
(async () => {
const request = async (route, body) => {
const r = await fetch(base + route, {
method: body ? "POST" : "GET",
headers,
body: body && JSON.stringify(body),
signal: AbortSignal.timeout(15000),
});
return { status: r.status, body: await r.json() };
};
assert.equal((await fetch(base + "/health")).status, 401);
const input = {
id: sha("upscale-qa-" + crypto.randomUUID()),
tenant: sha("isolated-qa"),
plan: { url: source, resolution: "4K" },
};
assert.equal((await request("/v1/upscales", input)).status, 202);
assert.equal((await request("/v1/upscales", input)).status, 200);
assert.equal(
(
await request("/v1/upscales", {
...input,
plan: { ...input.plan, resolution: "2K" },
})
).status,
409
);
const host = process.env.VIDEO_UPSCALE_QA_SSH_HOST,
control = process.env.VIDEO_UPSCALE_QA_SSH_CONTROL;
if (host) {
for (let i = 0; i < 30; i++) {
if ((await request("/v1/upscales/" + input.id)).body.status === "running")
break;
await wait(100);
}
execFileSync(
"ssh",
[
...(control ? ["-S", control] : []),
"-o",
"BatchMode=yes",
host,
"systemctl restart video-upscale",
],
{ timeout: 30000, stdio: "pipe" }
);
await wait(1500);
assert.equal((await request("/v1/upscales", input)).status, 200);
}
let result;
for (let i = 0; i < 240; i++) {
result = (await request("/v1/upscales/" + input.id)).body;
if (["succeeded", "failed"].includes(result.status)) break;
await wait(2000);
}
assert.equal(result.status, "succeeded", JSON.stringify(result));
assert.equal(result.result.width, 3840);
assert.equal(result.result.height, 2160);
assert.equal(result.result.frames, 2);
assert.equal(result.result.audio, true);
if (host) assert.equal(result.attempts, 2);
const response = await fetch(base + "/v1/upscales/" + input.id + "/video", {
headers,
});
assert(response.ok);
fs.mkdirSync(".qa/video-upscale-service", { recursive: true });
fs.writeFileSync(
".qa/video-upscale-service/result.mp4",
Buffer.from(await response.arrayBuffer())
);
assert.equal(
(await fetch(base + "/v1/upscales/" + input.id + "/video")).status,
401
);
fs.writeFileSync(
".qa/video-upscale-service/report.json",
JSON.stringify(result, null, 2)
);
console.log(
"PASS: GPU upscale, authenticated download, idempotency, conflicting parameters, restart recovery, dimensions/frame count/audio"
);
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});

View File

@ -0,0 +1,46 @@
# 独立视频高清服务
主项目负责用户/项目鉴权、持久任务、归档;此服务只下载视频并在独立 GPU 上逐帧超分。没有 CPU 回退,不使用千问显卡。当前部署固定 `CUDA_VISIBLE_DEVICES=3`,GPU 2 有硬件故障历史,不分配给本服务。
## 模型和环境
使用 [Real-ESRGAN](https://github.com/xinntao/Real-ESRGAN) 的 `realesr-general-x4v3`,官方 SRVGGNetCompact 架构(64 通道、32 卷积层、PReLU、4 倍输出);源码与 BSD-3-Clause 许可位于 `vendor/`,来源 commit 见 `vendor/SOURCE.md`。不是 Topaz 模型。模型文件不提交仓库。
权重:[官方 v0.2.5.0 发布](https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth),SHA-256 `8dc7edb9ac80ccdc30c3a5dca6616509367f05fbc184ad95b731f05bece96292`,推理前强校验。
P40 环境实测:Python 3.12.3、PyTorch 2.5.1+cu121、NumPy 1.26.4、Pillow 10.4.0、系统 FFmpeg/ffprobe(libx264/AAC)。PyTorch 使用 CUDA 12.1 官方 wheel 源;必须固定此版本,不能未经 P40 验证直接升级 CUDA/PyTorch。FP32 推理,192px tile + 40px halo,整帧输出在 CPU 内存,避免全帧网络推理耗尽显存。每个服务同时处理一个任务。
## 部署与 API
构建:`node services/video-upscale/build.cjs`,将 `dist/` 内容放到 `/opt/video-upscale/app/`。环境位于 `/opt/video-upscale/venv/`,权重 `/opt/video-upscale/models/realesr-general-x4v3.pth`。参考 `video-upscale.service` 创建专用 `video-upscale` 用户和其拥有的 `/var/lib/video-upscale`。
`/etc/video-upscale.env` 权限 600,包含 `UPSCALE_API_TOKEN`(至少 32 字符)、`UPSCALE_HOST=127.0.0.1`、`UPSCALE_PORT=18341`、`UPSCALE_DATA_DIR=/var/lib/video-upscale`。禁止将凭据放进 Git 或浏览器。
所有 API 要求 `Authorization: Bearer <token>`:
- `GET /health`:服务、队列和 GPU 配置。
- `POST /v1/upscales`:`{id,tenant,plan:{url,resolution}}`,id/tenant 均为 SHA-256 标识,resolution 为 `1080P`/`2K`/`4K`。
- `GET /v1/upscales/:id`:状态、阶段、帧数/帧率/音轨、尺寸与时长。
- `GET /v1/upscales/:id/video`:鉴权读取结果。
48 服务器使用 `video-upscale-tunnel.service`,复用已有专用隧道账号和固定 known_hosts,只扩展该公钥的 `permitlisten` 到 `127.0.0.1:18341`;原 18340 合成隧道不变。主项目接入变量为 `VIDEO_UPSCALE_URL=http://127.0.0.1:18341` 和 `VIDEO_UPSCALE_TOKEN`,部署时从 root-only `/etc/video-upscale-client.env` 注入,不能依赖本机临时 SSH 转发。
任务落盘 fsync + rename;相同 ID/参数幂等,相同 ID/不同参数 409。服务重启保留 running 记录,最多恢复两次;`KillMode=mixed` 先通知 Node 保存关闭状态,再终止子进程,避免把正常重启误记为推理失败。主项目保存远程 ID/服务指纹,轮询超时、读取/归档失败继续查询同一任务。
## 处理边界
输入 ≤125 MiB、≤120 秒、≤60fps、≤4K 像素数;支持 MP4/MOV、Matroska/WebM、AVI、FLV、MPEG-TS。仅恒定帧率;VFR 明确拒绝,不能静默改变音画同步。手机 90/180/270 度旋转信息会归一化,保留帧数、帧率、音轨。音频重编码 AAC,视频 H.264/yuv420p + faststart。不做补帧。
目标以 1920×1080 / 2560×1440 / 3840×2160 为横屏边界,竖屏交换宽高,等比例缩放、偶数尺寸。目标必须大于原图且至多 4 倍;目标已满足或要求超过 4 倍时拒绝,不拿插值假称模型支持更高倍率。网络输出可能产生细节变化,不能保证真实细节还原。
单任务超时 2 小时;≤30 个在途任务,≤200 个保留任务,完成/失败保留 7 天;可用磁盘 <10 GiB 拒绝开始。输入禁止内网/DNS 重绑定/不安全重定向,FFmpeg 网络协议关闭;输出下载 ≤300 MiB。systemd 内存 8 GiB、CPU 4 核、任务/线程 256,滤镜线程固定 1,避免 128 核宿主机默认线程数导致编码失败。
## 验收
`scripts/qa/video-upscale-service.cjs` 验证真实 API、GPU、音轨/帧数/尺寸、重复提交、参数冲突、未鉴权下载及重启恢复。需设置 `VIDEO_UPSCALE_QA_URL`、`VIDEO_UPSCALE_QA_TOKEN`、`VIDEO_UPSCALE_QA_SOURCE`(960×540、2fps、1 秒、含音轨的合成测试片);可选 `VIDEO_UPSCALE_QA_SSH_HOST` / `VIDEO_UPSCALE_QA_SSH_CONTROL` 执行独立服务重启。只重启视频高清服务,不重启千问或主项目。
测试素材生成示例(应上传到获准访问的测试存储域名):
```sh
ffmpeg -f lavfi -i 'testsrc2=size=960x540:rate=2:duration=1' -f lavfi -i 'sine=frequency=440:sample_rate=48000:duration=1' -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest landscape.mp4
```

View File

@ -0,0 +1,32 @@
const fs = require("node:fs"),
path = require("node:path");
const esbuild = require(
path.resolve(
"node_modules/.pnpm",
fs
.readdirSync("node_modules/.pnpm")
.find((n) => n.startsWith("esbuild@0.25.")),
"node_modules/esbuild"
)
);
esbuild
.build({
entryPoints: ["services/video-upscale/server.ts"],
outfile: "services/video-upscale/dist/server.cjs",
bundle: true,
platform: "node",
target: "node22",
format: "cjs",
})
.then(() => {
fs.copyFileSync(
"services/video-upscale/upscale.py",
"services/video-upscale/dist/upscale.py"
);
fs.cpSync(
"services/video-upscale/vendor",
"services/video-upscale/dist/vendor",
{ recursive: true }
);
})
.catch(() => process.exit(1));

View File

@ -0,0 +1,85 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { downloadCanvasMedia } from "../../lib/video-canvas/download-media";
import {
videoUpscaleSchema,
type VideoUpscaleInput,
} from "../../lib/video-canvas/video-upscale-contract";
export async function upscaleJob(
value: VideoUpscaleInput,
_tenant: string,
dir: string,
progress: (stage: string) => Promise<void>
) {
const input = videoUpscaleSchema.parse(value);
const file = path.join(dir, "input.bin");
await downloadCanvasMedia(input.url, file);
await progress("GPU 正在进行 AI 超分");
return new Promise<any>((resolve, reject) => {
const child = spawn(
process.env.UPSCALE_PYTHON || "/opt/video-upscale/venv/bin/python",
[
process.env.UPSCALE_SCRIPT || path.join(__dirname, "upscale.py"),
"--input",
file,
"--output",
path.join(dir, "result.mp4"),
"--resolution",
input.resolution,
"--model",
process.env.UPSCALE_MODEL ||
"/opt/video-upscale/models/realesr-general-x4v3.pth",
],
{ stdio: ["ignore", "pipe", "pipe"], detached: true }
);
let buffer = "",
result: any,
error = "",
stages = Promise.resolve();
const timer = setTimeout(() => {
try {
process.kill(-child.pid!, "SIGKILL");
} catch {}
}, 2 * 3600000);
child.stdout.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop()!;
for (const line of lines)
try {
const value = JSON.parse(line);
if (value.result) result = value.result;
if (value.stage)
stages = stages
.then(() => progress(value.stage))
.catch(() => {
try {
process.kill(-child.pid!, "SIGKILL");
} catch {}
});
} catch {}
});
child.stderr.on("data", (chunk) => {
error = (error + chunk).slice(-4000);
});
child.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
child.on("close", async (code) => {
clearTimeout(timer);
await stages.catch(() => {});
if (code !== 0 || !result) {
console.error(
"Upscale inference failed",
error.replace(/https?:\/\/\S+/g, "[url]")
);
reject(
Error(
"AI 超分失败,请检查视频是否为恒定帧率、120 秒以内,且放大倍率为 1–4 倍"
)
);
} else resolve({ ...result, duration: result.durationSeconds });
});
});
}

View File

@ -0,0 +1,328 @@
import http from "node:http";
import { createReadStream } from "node:fs";
import {
mkdir,
readFile,
rename,
readdir,
rm,
stat,
statfs,
open,
} from "node:fs/promises";
import path from "node:path";
import { createHash, timingSafeEqual } from "node:crypto";
import { pipeline } from "node:stream/promises";
import { z } from "zod";
import {
videoUpscaleSchema,
type VideoUpscaleInput,
} from "../../lib/video-canvas/video-upscale-contract";
import { upscaleJob } from "./job";
const root = path.resolve(
process.env.UPSCALE_DATA_DIR || "/var/lib/video-upscale"
);
const token = process.env.UPSCALE_API_TOKEN || "";
if (token.length < 32)
throw Error("UPSCALE_API_TOKEN must have at least 32 characters");
const jobs = new Map<string, Job>();
type Job = {
id: string;
digest: string;
tenant: string;
plan: VideoUpscaleInput;
status: "queued" | "running" | "succeeded" | "failed";
stage: string;
createdAt: string;
updatedAt: string;
attempts: number;
error?: string;
result?: Record<string, any>;
};
const inputSchema = z.object({
id: z.string().regex(/^[a-f0-9]{64}$/),
tenant: z.string().regex(/^[a-f0-9]{64}$/),
plan: videoUpscaleSchema,
});
let busy = false,
stopping = false,
accepting = false;
const file = (id: string) => path.join(root, id + ".json");
async function persist(job: Job) {
job.updatedAt = new Date().toISOString();
const temp = file(job.id) + ".tmp";
const handle = await open(temp, "w", 0o600);
try {
await handle.writeFile(JSON.stringify(job));
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, file(job.id));
const directory = await open(root, "r");
try {
await directory.sync();
} finally {
await directory.close();
}
jobs.set(job.id, job);
}
function publicJob(job: Job) {
return {
id: job.id,
status: job.status,
stage: job.stage,
attempts: job.attempts,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
error: job.error,
result: job.result,
};
}
async function prune() {
for (const job of jobs.values())
if (
["succeeded", "failed"].includes(job.status) &&
Date.now() - Date.parse(job.updatedAt) > 7 * 86400000
) {
await rm(path.join(root, job.id), { recursive: true, force: true });
await rm(file(job.id), { force: true });
jobs.delete(job.id);
}
}
async function work() {
if (busy || stopping) return;
const job = [...jobs.values()].find((j) => j.status === "queued");
if (!job) return;
busy = true;
const dir = path.join(root, job.id);
try {
await prune();
const disk = await statfs(root);
if (disk.bavail * disk.bsize < 10 * 1024 ** 3)
throw Error("超分磁盘剩余空间不足 10 GiB");
job.status = "running";
job.attempts++;
job.stage = "准备素材";
await persist(job);
await rm(dir, { recursive: true, force: true });
await mkdir(dir, { mode: 0o700 });
const result = await upscaleJob(
job.plan,
job.tenant,
dir,
async (stage) => {
job.stage = stage;
await persist(job);
}
);
job.result = result;
// Keep only the deliverables. Intermediate files must not accumulate per job.
for (const name of await readdir(dir))
if (name !== "result.mp4")
await rm(path.join(dir, name), { recursive: true, force: true }).catch(
() => {
console.warn(
JSON.stringify({ event: "upscale-cleanup-deferred", id: job.id })
);
}
);
job.status = "succeeded";
job.stage = "超分完成";
await persist(job);
} catch (error) {
if (stopping) return; // Preserve running journal; restart will resume this same job.
job.status = "failed";
job.stage = "超分失败";
// Do not expose ffmpeg arguments, private cache paths or signed source URLs.
job.error =
error instanceof Error &&
!/Command failed|https?:|\/var\/|\/tmp\//.test(error.message)
? error.message.slice(0, 300)
: "素材处理失败,请检查格式、参数或服务日志";
await rm(dir, { recursive: true, force: true }).catch(() => {});
await persist(job);
console.error(
JSON.stringify({ event: "upscale-failed", id: job.id, error: job.error })
);
} finally {
busy = false;
}
setImmediate(
() =>
void work().catch((e) => {
console.error("Render queue persistence failure");
process.exit(1);
})
);
}
function json(res: http.ServerResponse, status: number, value: unknown) {
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
res.end(JSON.stringify(value));
}
async function body(req: http.IncomingMessage) {
let size = 0;
const chunks: Buffer[] = [];
for await (const chunk of req) {
size += chunk.length;
if (size > 1024 * 1024) throw Error("请求超过 1 MiB");
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function authenticated(req: http.IncomingMessage) {
const expected = Buffer.from("Bearer " + token),
actual = Buffer.from(req.headers.authorization || "");
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
async function main() {
await mkdir(root, { recursive: true, mode: 0o700 });
// This service runs as a single systemd process. A restart requeues local work at most twice.
for (const name of await readdir(root))
if (/^[a-f0-9]{64}\.json$/.test(name)) {
const job: Job = JSON.parse(
await readFile(path.join(root, name), "utf8")
);
if (job.id + ".json" !== name) throw Error("Invalid upscale record");
if (job.status === "running") {
job.status = job.attempts < 3 ? "queued" : "failed";
job.stage =
job.status === "queued"
? "服务重启,等待恢复超分"
: "多次中断,已停止";
if (job.status === "failed") job.error = job.stage;
await persist(job);
}
jobs.set(job.id, job);
}
await prune();
const server = http.createServer(async (req, res) => {
if (!authenticated(req)) return json(res, 401, { error: "Unauthorized" });
const url = new URL(req.url || "/", "http://localhost");
try {
if (req.method === "GET" && url.pathname === "/health")
return json(res, 200, {
status: "ok",
version: 1,
active: busy ? 1 : 0,
queued: [...jobs.values()].filter((j) => j.status === "queued")
.length,
render: "realesr-general-x4v3",
gpu: process.env.CUDA_VISIBLE_DEVICES,
});
if (req.method === "POST" && url.pathname === "/v1/upscales") {
const input = inputSchema.parse(await body(req));
// Serialize admission across the async filesystem write to preserve idempotency.
if (accepting) return json(res, 503, { error: "请重试提交" });
accepting = true;
try {
const digest = createHash("sha256")
.update(JSON.stringify({ tenant: input.tenant, plan: input.plan }))
.digest("hex");
const existing = jobs.get(input.id);
if (existing)
return json(
res,
existing.digest === digest ? 200 : 409,
existing.digest === digest
? publicJob(existing)
: { error: "任务 ID 已用于不同参数" }
);
if (
jobs.size >= 200 ||
[...jobs.values()].filter((j) =>
["queued", "running"].includes(j.status)
).length >= 30
)
return json(res, 429, { error: "超分队列已满,请稍后重试" });
const job: Job = {
...input,
digest,
status: "queued",
stage: "排队中",
attempts: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await persist(job);
json(res, 202, publicJob(job));
void work().catch(() => {
console.error("Render queue failed");
process.exit(1);
});
return;
} finally {
accepting = false;
}
}
const match = url.pathname.match(
/^\/v1\/upscales\/([a-f0-9]{64})(?:\/(video))?$/
);
if (req.method === "GET" && match) {
const job = jobs.get(match[1]);
if (!job) return json(res, 404, { error: "任务不存在或已过期" });
if (!match[2]) return json(res, 200, publicJob(job));
if (job.status !== "succeeded")
return json(res, 409, { error: "成片尚未就绪" });
const output = path.join(root, job.id, "result.mp4");
const info = await stat(output);
res.writeHead(200, {
"content-type": "video/mp4",
"content-length": info.size,
"cache-control": "no-store",
});
await pipeline(createReadStream(output), res);
return;
}
json(res, 404, { error: "Not found" });
} catch (error) {
if (res.headersSent) {
res.destroy();
return;
}
json(res, 400, {
error:
error instanceof z.ZodError
? "超分参数无效"
: error instanceof Error && !/https?:|\/var\//.test(error.message)
? error.message.slice(0, 300)
: "请求处理失败",
});
}
});
server.requestTimeout = 120000;
server.headersTimeout = 10000;
server.listen(
Number(process.env.UPSCALE_PORT || 18341),
process.env.UPSCALE_HOST || "127.0.0.1"
);
const fatal = () => {
console.error("Render queue persistence failure");
process.exit(1);
};
const timer = setInterval(() => {
void work().catch(fatal);
}, 3000);
const cleanupTimer = setInterval(() => {
if (!busy && !accepting) void prune().catch(fatal);
}, 3600000);
cleanupTimer.unref();
for (const signal of ["SIGTERM", "SIGINT"])
process.on(signal, () => {
stopping = true;
clearInterval(timer);
clearInterval(cleanupTimer);
server.close();
// systemd KillMode=mixed also terminates FFmpeg; persisted running jobs resume on restart.
setTimeout(() => process.exit(0), 1000).unref();
});
console.log("Video upscale service ready");
}
void main().catch(() => {
console.error("Video upscale service startup failed");
process.exitCode = 1;
});

View File

@ -0,0 +1,79 @@
"""Bounded, tiled Real-ESRGAN video inference. No CPU fallback or frame interpolation."""
import argparse, json, subprocess, sys, os, hashlib
from fractions import Fraction
from pathlib import Path
import numpy as np
import torch
from PIL import Image
sys.path.insert(0, str(Path(__file__).parent / 'vendor'))
from srvgg_arch import SRVGGNetCompact
def probe(file):
return json.loads(subprocess.check_output(['ffprobe','-v','error','-protocol_whitelist','file,pipe','-show_streams','-show_format','-of','json',file], timeout=30))
def main():
p=argparse.ArgumentParser()
for name in ['input','output','model','resolution']: p.add_argument('--'+name, required=True)
p.add_argument('--tile', type=int, default=192)
a=p.parse_args()
if not torch.cuda.is_available(): raise RuntimeError('CUDA unavailable; refusing CPU fallback')
torch.set_num_threads(4)
info=probe(a.input); v=next(s for s in info['streams'] if s['codec_type']=='video')
if not any(name in info['format']['format_name'].split(',') for name in ['mov','mp4','matroska','webm','avi','flv','mpegts']): raise ValueError('Unsupported video container')
rotation=next((int(side['rotation'])%360 for side in v.get('side_data_list',[]) if 'rotation' in side),0)
if rotation%90: raise ValueError('Unsupported rotation angle')
w,h=int(v['width']),int(v['height'])
if rotation in [90,270]: w,h=h,w
fps=Fraction(v['avg_frame_rate'])
duration=float(info['format']['duration'])
if not 0<duration<=120 or not 0<float(fps)<=60 or w*h>3840*2160: raise ValueError('Input exceeds 120 s / 60 fps / 4K limits')
# Reject variable frame rate rather than silently altering timing/audio sync.
frame_times=subprocess.check_output(['ffprobe','-v','error','-protocol_whitelist','file,pipe','-select_streams','v:0','-show_entries','frame=best_effort_timestamp_time','-of','csv=p=0',a.input],timeout=60).decode().splitlines()
timestamps=[float(s.split(',')[0]) for s in frame_times if s.split(',')[0].strip()]
if any(abs((b-c)-float(1/fps))>max(.002,float(1/fps)*.05) for c,b in zip(timestamps,timestamps[1:])): raise ValueError('Variable frame rate video must be converted to constant frame rate first')
long,short={'1080P':(1920,1080),'2K':(2560,1440),'4K':(3840,2160)}[a.resolution]
scale=min((long if w>=h else short)/w,(short if w>=h else long)/h)
if not 1<scale<=4: raise ValueError('Target must exceed source resolution by no more than 4x')
ow,oh=int(w*scale)//2*2,int(h*scale)//2*2
model=SRVGGNetCompact(num_in_ch=3,num_out_ch=3,num_feat=64,num_conv=32,upscale=4,act_type='prelu')
if hashlib.sha256(Path(a.model).read_bytes()).hexdigest() != '8dc7edb9ac80ccdc30c3a5dca6616509367f05fbc184ad95b731f05bece96292': raise RuntimeError('Model checksum mismatch')
weights=torch.load(a.model,map_location='cpu',weights_only=True)
model.load_state_dict(weights.get('params_ema',weights.get('params',weights)),strict=True)
model.eval().to('cuda')
temp=a.output+'.silent.mp4'
decode=subprocess.Popen(['ffmpeg','-v','error','-filter_threads','1','-filter_complex_threads','1','-threads','2','-protocol_whitelist','file,pipe','-i',a.input,'-map','0:v:0','-f','rawvideo','-pix_fmt','rgb24','-vsync','0','pipe:1'],stdout=subprocess.PIPE)
encode=subprocess.Popen(['ffmpeg','-v','error','-filter_threads','1','-filter_complex_threads','1','-y','-f','rawvideo','-pix_fmt','rgb24','-s',f'{ow}x{oh}','-r',str(fps),'-i','pipe:0','-an','-c:v','libx264','-threads','4','-preset','fast','-crf','18','-pix_fmt','yuv420p',temp],stdin=subprocess.PIPE)
count=0
try:
with torch.inference_mode():
while True:
raw=decode.stdout.read(w*h*3)
if not raw: break
if len(raw)!=w*h*3: raise RuntimeError('Truncated video frame')
source=np.frombuffer(raw,dtype=np.uint8).reshape(h,w,3)
# CPU output buffer, bounded GPU tiles with enough halo for 35 convolutions.
output=np.empty((h*4,w*4,3),dtype=np.uint8)
for y in range(0,h,a.tile):
for x in range(0,w,a.tile):
x0,y0=max(0,x-40),max(0,y-40); x1,y1=min(w,x+a.tile+40),min(h,y+a.tile+40)
tile=torch.from_numpy(source[y0:y1,x0:x1].copy()).permute(2,0,1).float().unsqueeze(0).to('cuda')/255
enhanced=model(tile).clamp_(0,1).squeeze(0).permute(1,2,0).mul_(255).round_().byte().cpu().numpy()
ex,ey=min(w,x+a.tile),min(h,y+a.tile)
output[y*4:ey*4,x*4:ex*4]=enhanced[(y-y0)*4:(ey-y0)*4,(x-x0)*4:(ex-x0)*4]
frame=Image.fromarray(output)
if (ow,oh)!=(w*4,h*4): frame=frame.resize((ow,oh),Image.Resampling.LANCZOS)
encode.stdin.write(frame.tobytes()); count+=1
if count%5==0: print(json.dumps({'stage':f'AI 超分 {count}/{len(timestamps)} 帧'}),flush=True)
encode.stdin.close()
if decode.wait(timeout=30)!=0 or encode.wait(timeout=120)!=0: raise RuntimeError('Video codec failed')
finally:
for process in [decode,encode]:
if process.poll() is None: process.kill(); process.wait()
if count!=len(timestamps) or count==0: raise RuntimeError('Output frame count mismatch')
subprocess.run(['ffmpeg','-v','error','-filter_threads','1','-filter_complex_threads','1','-y','-protocol_whitelist','file,pipe','-i',temp,'-protocol_whitelist','file,pipe','-i',a.input,'-map','0:v:0','-map','1:a?','-map_metadata','1','-metadata:s:v:0','rotate=0','-c:v','copy','-c:a','aac','-b:a','192k','-movflags','+faststart',a.output],check=True,timeout=120)
actual=probe(a.output); stream=next(s for s in actual['streams'] if s['codec_type']=='video')
if int(stream['nb_frames'])!=count or abs(float(actual['format']['duration'])-duration)>.2: raise RuntimeError('Output timing mismatch')
if any(s['codec_type']=='audio' for s in actual['streams']) != any(s['codec_type']=='audio' for s in info['streams']): raise RuntimeError('Output audio stream missing')
os.unlink(temp)
print(json.dumps({'result':{'width':ow,'height':oh,'durationSeconds':float(actual['format']['duration']),'fps':float(fps),'frames':count,'audio':any(s['codec_type']=='audio' for s in actual['streams']),'engine':'Real-ESRGAN','model':'realesr-general-x4v3'}}),flush=True)
if __name__=='__main__': main()

29
services/video-upscale/vendor/LICENSE vendored Normal file
View File

@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2021, Xintao Wang
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -0,0 +1,4 @@
Source: https://github.com/xinntao/Real-ESRGAN
Commit: a4abfb2979a7bbff3f69f58f58ae324608821e27
BSD-3-Clause license, retained alongside source.
Only the BasicSR registry import/decorator is removed; inference architecture is unchanged.

View File

@ -0,0 +1,67 @@
from torch import nn as nn
from torch.nn import functional as F
class SRVGGNetCompact(nn.Module):
"""A compact VGG-style network structure for super-resolution.
It is a compact network structure, which performs upsampling in the last layer and no convolution is
conducted on the HR feature space.
Args:
num_in_ch (int): Channel number of inputs. Default: 3.
num_out_ch (int): Channel number of outputs. Default: 3.
num_feat (int): Channel number of intermediate features. Default: 64.
num_conv (int): Number of convolution layers in the body network. Default: 16.
upscale (int): Upsampling factor. Default: 4.
act_type (str): Activation type, options: 'relu', 'prelu', 'leakyrelu'. Default: prelu.
"""
def __init__(self, num_in_ch=3, num_out_ch=3, num_feat=64, num_conv=16, upscale=4, act_type='prelu'):
super(SRVGGNetCompact, self).__init__()
self.num_in_ch = num_in_ch
self.num_out_ch = num_out_ch
self.num_feat = num_feat
self.num_conv = num_conv
self.upscale = upscale
self.act_type = act_type
self.body = nn.ModuleList()
# the first conv
self.body.append(nn.Conv2d(num_in_ch, num_feat, 3, 1, 1))
# the first activation
if act_type == 'relu':
activation = nn.ReLU(inplace=True)
elif act_type == 'prelu':
activation = nn.PReLU(num_parameters=num_feat)
elif act_type == 'leakyrelu':
activation = nn.LeakyReLU(negative_slope=0.1, inplace=True)
self.body.append(activation)
# the body structure
for _ in range(num_conv):
self.body.append(nn.Conv2d(num_feat, num_feat, 3, 1, 1))
# activation
if act_type == 'relu':
activation = nn.ReLU(inplace=True)
elif act_type == 'prelu':
activation = nn.PReLU(num_parameters=num_feat)
elif act_type == 'leakyrelu':
activation = nn.LeakyReLU(negative_slope=0.1, inplace=True)
self.body.append(activation)
# the last conv
self.body.append(nn.Conv2d(num_feat, num_out_ch * upscale * upscale, 3, 1, 1))
# upsample
self.upsampler = nn.PixelShuffle(upscale)
def forward(self, x):
out = x
for i in range(0, len(self.body)):
out = self.body[i](out)
out = self.upsampler(out)
# add the nearest upsampled image, so that the network learns the residual
base = F.interpolate(x, scale_factor=self.upscale, mode='nearest')
out += base
return out

View File

@ -0,0 +1,17 @@
[Unit]
Description=Private upscale API tunnel to main application
After=network-online.target video-upscale.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/ssh -NT -o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=20 -o ServerAliveCountMax=3 -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/etc/video-render-tunnel/known_hosts -i /etc/video-render-tunnel/id_ed25519 -p 64192 -R 127.0.0.1:18341:127.0.0.1:18341 video-render-tunnel@45.192.99.48
Restart=always
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,33 @@
[Unit]
Description=Isolated Real-ESRGAN video upscale API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=video-upscale
Group=video-upscale
WorkingDirectory=/opt/video-upscale/app
EnvironmentFile=/etc/video-upscale.env
Environment=CUDA_VISIBLE_DEVICES=3
Environment=OMP_NUM_THREADS=4
Environment=OPENBLAS_NUM_THREADS=4
Environment=MKL_NUM_THREADS=4
Environment=UPSCALE_PYTHON=/opt/video-upscale/venv/bin/python
ExecStart=/usr/bin/node /opt/video-upscale/app/server.cjs
Restart=on-failure
RestartSec=5
KillMode=mixed
TimeoutStopSec=15
UMask=0077
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/video-upscale
MemoryMax=8G
CPUQuota=400%
TasksMax=256
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,310 @@
import { test, expect } from "@playwright/test";
import { randomUUID } from "node:crypto";
import {
makeCanvasGroup,
groupMembers,
moveCanvasSelection,
ungroupCanvasNodes,
layoutCanvasGroup,
exportCanvasGroup,
instantiateCanvasGroup,
migrateLegacyGroups,
assignGroupMembers,
type GroupNode,
} from "@/lib/video-canvas/groups";
import {
compileGroupRun,
validateGroupBatch,
groupDependencyState,
resolveGroupInputs,
} from "@/lib/video-canvas/group-execution";
import {
upscaleDimensions,
videoUpscaleSchema,
} from "@/lib/video-canvas/video-upscale-contract";
import {
reconcileCanvasTasks,
type CanvasGenerationTask,
type CanvasTaskRequest,
} from "@/lib/video-canvas/task-contract";
import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery";
const n = (id: string, x = 100, y = 100): GroupNode => ({
id,
title: id,
kind: "media",
inputType: "image",
x,
y,
width: 200,
height: 100,
draftValues: {
action: "IMAGE_RESOURCE",
mediaUrl: `https://media.example/${id}.png`,
},
});
const edge = (source: string, target: string) => ({
id: `${source}-${target}`,
source,
target,
});
const task = (
request: CanvasTaskRequest,
status = "pending",
result: any = null
): CanvasGenerationTask => ({
id: randomUUID(),
requestId: request.requestId,
request,
status,
result,
createdAt: new Date().toISOString(),
});
function batch() {
const source = n("source"),
image = {
...n("image", 400),
draftValues: {
action: "IMAGE_GENERATE",
params: { prompt: "产品" },
mediaUrl: "https://media.example/OLD.png",
},
},
video = {
...n("video", 700),
inputType: "video",
draftValues: { action: "VIDEO_GENERATE" },
},
hd = {
...n("hd", 1000),
inputType: "video",
draftValues: { action: "VIDEO_HD_UPSCALE" },
};
const nodes = makeCanvasGroup(
[source, image, video, hd],
["source", "image", "video", "hd"],
"group"
);
return compileGroupRun(
nodes,
[edge("source", "image"), edge("image", "video"), edge("video", "hd")],
"group",
randomUUID(),
(node) =>
node.id === "image"
? { kind: "image", params: { prompt: "产品" } }
: node.id === "video"
? { kind: "video", params: { prompt: "运镜", mode: "first-frame" } }
: { kind: "video-upscale", params: { resolution: "4K" } },
randomUUID
);
}
test("nested groups move descendants exactly once and ungroup preserves edges/member coordinates", () => {
let nodes = makeCanvasGroup(
[n("a"), n("b", 400), n("c", 800)],
["a", "b"],
"inner"
);
nodes = makeCanvasGroup(nodes, ["inner", "c"], "outer");
const before = nodes;
nodes = moveCanvasSelection(nodes, ["outer", "inner", "a"], 50, 75);
for (const node of nodes) {
const old = before.find((n) => n.id === node.id)!;
expect([node.x, node.y]).toEqual([old.x + 50, old.y + 75]);
}
const ungrouped = ungroupCanvasNodes(nodes, "inner");
expect(
groupMembers(ungrouped, "outer")
.map((n) => n.id)
.sort()
).toEqual(["a", "b", "c"]);
expect(ungrouped.find((n) => n.id === "a")?.x).toBe(
nodes.find((n) => n.id === "a")?.x
);
expect(() => assignGroupMembers(before, ["outer"], "inner")).toThrow("子组");
expect(
groupMembers(assignGroupMembers(before, ["a"], ""), "inner")
).toHaveLength(1);
});
test("layout fits frame, legacy preset migrates once, export/import remaps internal IDs only", () => {
const nodes = makeCanvasGroup([n("a"), n("b", 400)], ["a", "b"], "g");
const laid = layoutCanvasGroup(nodes, "g", "vertical");
expect(laid.find((n) => n.id === "a")?.x).toBe(
laid.find((n) => n.id === "b")?.x
);
const template = exportCanvasGroup(
[...laid, n("external", 900)],
[edge("a", "b"), edge("external", "a")],
"g"
);
const imported = instantiateCanvasGroup(template, randomUUID, 900, 300);
expect(imported.edges).toHaveLength(2);
expect(
imported.nodes.every((n) => !["a", "b", "g"].includes(n.id))
).toBeTruthy();
expect(groupMembers(imported.nodes, imported.groupId)).toHaveLength(3);
expect(
imported.nodes.some(
(n) => n.draftValues?.templateInput && !n.draftValues?.mediaUrl
)
).toBeTruthy();
const legacy = nodes.map((n) => ({
...n,
draftValues: {
...n.draftValues,
parentGroupId: undefined,
groupVersion: undefined,
},
}));
expect(groupMembers(migrateLegacyGroups(legacy), "g")).toHaveLength(2);
});
test("invalid imported member cycles and missing endpoints are rejected", () => {
const nodes = makeCanvasGroup([n("a"), n("b", 400)], ["a", "b"], "g"),
template = exportCanvasGroup(nodes, [], "g");
expect(() =>
instantiateCanvasGroup(
{ ...template, edges: [edge("missing", "a")] },
randomUUID,
0,
0
)
).toThrow("不存在");
const cyclic = template.nodes.map((n) =>
n.id === "g"
? { ...n, draftValues: { ...n.draftValues, parentGroupId: "g" } }
: n
);
expect(() =>
instantiateCanvasGroup({ ...template, nodes: cyclic }, randomUUID, 0, 0)
).toThrow("循环");
});
test("whole-group run resolves this run's fresh output through image > video > HD", () => {
const requests = batch();
validateGroupBatch(requests);
expect(requests).toHaveLength(3);
const tasks = requests.map((r) => task(r));
expect(groupDependencyState(requests[0], tasks)).toBe("ready");
expect(groupDependencyState(requests[1], tasks)).toBe("waiting");
tasks[0].status = "succeeded";
tasks[0].result = {
images: [{ originalUrl: "https://media.example/FRESH.png" }],
};
expect(resolveGroupInputs(requests[1], tasks).images).toEqual([
"https://media.example/FRESH.png",
]);
tasks[1].status = "succeeded";
tasks[1].result = { url: "https://media.example/NEW.mp4" };
expect(resolveGroupInputs(requests[2], tasks).url).toBe(
"https://media.example/NEW.mp4"
);
});
test("failed/cancelled/uncertain upstream blocks downstream without using old output", () => {
const requests = batch();
for (const status of ["failed", "cancelled", "interrupted"]) {
const tasks = requests.map((r) => task(r));
tasks[0].status = status;
expect(groupDependencyState(requests[1], tasks)).toBe("failed");
expect(() => resolveGroupInputs(requests[1], tasks)).toThrow("尚未成功");
}
});
test("batch rejects foreign dependency, wrong edge, cycle and mixed projects", () => {
let r = batch();
r[1].workflow!.inputs[0].requestId = randomUUID();
expect(() => validateGroupBatch(r)).toThrow("本次编组");
r = batch();
r[1].edges = [];
expect(() => validateGroupBatch(r)).toThrow("连线");
r = batch();
r[1].projectId = randomUUID();
expect(() => validateGroupBatch(r)).toThrow("同一画布");
r = batch().slice(0, 2);
r[0].workflow!.inputs = [{ node: r[1].node, requestId: r[1].requestId }];
r[0].edges = [edge(r[1].node.id, r[0].node.id)];
r[0].node.inputType = "text";
r[1].node.inputType = "text";
r[0].workflow!.inputs[0].node.inputType = "text";
r[1].workflow!.inputs[0].node.inputType = "text";
expect(() => validateGroupBatch(r)).toThrow("循环");
});
test("video HD dimensions preserve aspect and enforce meaningful 1–4x AI scale", () => {
expect(upscaleDimensions(960, 540, "4K")).toEqual({
width: 3840,
height: 2160,
});
expect(upscaleDimensions(540, 960, "4K")).toEqual({
width: 2160,
height: 3840,
});
expect(upscaleDimensions(640, 480, "2K")).toEqual({
width: 1920,
height: 1440,
});
expect(() => upscaleDimensions(1920, 1080, "1080P")).toThrow("已达到");
expect(() => upscaleDimensions(320, 180, "4K")).toThrow("4 倍");
expect(
videoUpscaleSchema.safeParse({
url: "file:///etc/passwd",
resolution: "4K",
}).success
).toBeFalsy();
});
test("HD task survives refresh with remote ID and becomes playable video", () => {
const request = batch()[2],
t = task(request, "interrupted", {
upscale: {
remote: { id: "a".repeat(64), endpointHash: "b".repeat(64) },
stage: "第 5 帧",
},
});
expect(hasRecoverableCanvasTask(t)).toBeTruthy();
t.status = "succeeded";
t.result = {
url: "https://media.example/hd.mp4",
metadata: { width: 3840, height: 2160 },
};
const result = reconcileCanvasTasks([request.node], [t], "2020-01-01")[0];
expect(result.inputType).toBe("video");
expect(result.draftValues?.mediaUrl).toBe(t.result.url);
expect(result.draftValues?.generationStatus).toBe("complete");
});
test("generated video feeds fresh smart-edit plan and automatic render as durable stages", () => {
const video = {
...n("v"),
inputType: "video",
draftValues: { action: "VIDEO_GENERATE" },
},
edit = {
...n("edit", 400),
inputType: "video",
draftValues: { action: "SMART_EDIT_WORKFLOW", nodeType: "smart-edit" },
};
const nodes = makeCanvasGroup([video, edit], ["v", "edit"], "g");
const requests = compileGroupRun(
nodes,
[edge("v", "edit")],
"g",
randomUUID(),
(node) =>
node.id === "v"
? { kind: "video", params: { mode: "text-to-video", prompt: "走动" } }
: {
kind: "smart-edit",
params: { operation: "plan", autoRender: true, mode: "sequence" },
},
randomUUID
);
expect(requests).toHaveLength(3);
validateGroupBatch(requests);
const tasks = requests.map((r) => task(r));
tasks[0].status = "succeeded";
tasks[0].result = { url: "https://media.example/fresh.mp4" };
expect(resolveGroupInputs(requests[1], tasks).assets).toEqual([
{ id: "group-asset-1", name: "v", url: "https://media.example/fresh.mp4" },
]);
tasks[1].status = "succeeded";
tasks[1].result = { plans: [{ title: "fresh plan" }] };
expect(resolveGroupInputs(requests[2], tasks).plan).toEqual({
title: "fresh plan",
});
expect(requests[1].hiddenNodeIds).toContain(requests[1].node.id);
});