Complete durable canvas groups and add isolated P40 video upscaling
This commit is contained in:
parent
b17b8b7689
commit
12576c565f
4
.gitignore
vendored
4
.gitignore
vendored
@ -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__/
|
||||
|
||||
@ -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);
|
||||
|
||||
39
app/api/canvas/group-runs/route.ts
Normal file
39
app/api/canvas/group-runs/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
40
app/api/canvas/group-templates/route.ts
Normal file
40
app/api/canvas/group-templates/route.ts
Normal 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" } }
|
||||
);
|
||||
}
|
||||
39
app/api/canvas/video-upscale/capabilities/route.ts
Normal file
39
app/api/canvas/video-upscale/capabilities/route.ts
Normal 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" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
125
components/canvas/CanvasGroupToolbar.tsx
Normal file
125
components/canvas/CanvasGroupToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
components/canvas/GroupStoryboardPreview.tsx
Normal file
122
components/canvas/GroupStoryboardPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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({
|
||||
|
||||
39
docs/video-canvas-groups-upscale-2026-09-18.md
Normal file
39
docs/video-canvas-groups-upscale-2026-09-18.md
Normal 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 账号。
|
||||
90
hooks/use-canvas-group-runs.ts
Normal file
90
hooks/use-canvas-group-runs.ts
Normal 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 };
|
||||
}
|
||||
@ -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"]
|
||||
|
||||
333
lib/video-canvas/group-execution.ts
Normal file
333
lib/video-canvas/group-execution.ts
Normal 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
398
lib/video-canvas/groups.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
33
lib/video-canvas/video-upscale-contract.ts
Normal file
33
lib/video-canvas/video-upscale-contract.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
181
lib/video-canvas/video-upscale-remote.ts
Normal file
181
lib/video-canvas/video-upscale-remote.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
62
lib/video-canvas/video-upscale.ts
Normal file
62
lib/video-canvas/video-upscale.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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/,
|
||||
|
||||
103
scripts/qa/video-upscale-service.cjs
Normal file
103
scripts/qa/video-upscale-service.cjs
Normal 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;
|
||||
});
|
||||
46
services/video-upscale/README.md
Normal file
46
services/video-upscale/README.md
Normal 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
|
||||
```
|
||||
32
services/video-upscale/build.cjs
Normal file
32
services/video-upscale/build.cjs
Normal 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));
|
||||
85
services/video-upscale/job.ts
Normal file
85
services/video-upscale/job.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
328
services/video-upscale/server.ts
Normal file
328
services/video-upscale/server.ts
Normal 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;
|
||||
});
|
||||
79
services/video-upscale/upscale.py
Normal file
79
services/video-upscale/upscale.py
Normal 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
29
services/video-upscale/vendor/LICENSE
vendored
Normal 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.
|
||||
4
services/video-upscale/vendor/SOURCE.md
vendored
Normal file
4
services/video-upscale/vendor/SOURCE.md
vendored
Normal 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.
|
||||
67
services/video-upscale/vendor/srvgg_arch.py
vendored
Normal file
67
services/video-upscale/vendor/srvgg_arch.py
vendored
Normal 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
|
||||
17
services/video-upscale/video-upscale-tunnel.service
Normal file
17
services/video-upscale/video-upscale-tunnel.service
Normal 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
|
||||
33
services/video-upscale/video-upscale.service
Normal file
33
services/video-upscale/video-upscale.service
Normal 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
|
||||
310
tests/routes/video-canvas-groups-upscale.test.ts
Normal file
310
tests/routes/video-canvas-groups-upscale.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user