diff --git a/app/api/canvas/generate/image/route.ts b/app/api/canvas/generate/image/route.ts index f31a24191..5060327d3 100644 --- a/app/api/canvas/generate/image/route.ts +++ b/app/api/canvas/generate/image/route.ts @@ -1,63 +1,10 @@ +import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response"; import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; -import { - createCanvasImageTask, - enqueueCanvasImageTask, - getCanvasImageTaskForUser, -} from "@/lib/canvas/image-task-queue"; -import { - generateCanvasImage, - type CanvasGenerateImageRequest, -} from "@/lib/canvas/generate-image"; -import { getPointsSystemConfig } from "@/lib/db/queries"; -import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; -import { - getImageToolCreditCost, - type ImageToolCreditFeature, -} from "@/lib/points/config"; +import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue"; export const maxDuration = 1200; -interface GenerateImageRequest extends CanvasGenerateImageRequest { - async?: boolean; -} - -function normalizeBillingFeature( - value: unknown -): Extract | null { - if (value === "multi-view" || value === "repaint") return value; - return null; -} - -function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest { - return { - allowFailover: typeof body.allowFailover === "boolean" ? body.allowFailover : undefined, - prompt: String(body.prompt || "").trim(), - negativePrompt: - typeof body.negativePrompt === "string" ? body.negativePrompt : undefined, - model: typeof body.model === "string" ? body.model : undefined, - aspectRatio: - typeof body.aspectRatio === "string" && body.aspectRatio.trim() - ? body.aspectRatio.trim() - : "auto", - imageSize: - typeof body.imageSize === "string" && body.imageSize.trim() - ? body.imageSize.trim() - : undefined, - imageCount: - typeof body.imageCount === "number" && Number.isFinite(body.imageCount) - ? body.imageCount - : 1, - promptExtend: - typeof body.promptExtend === "boolean" ? body.promptExtend : undefined, - referenceImage: - typeof body.referenceImage === "string" ? body.referenceImage : undefined, - referenceImages: Array.isArray(body.referenceImages) - ? body.referenceImages.map((item) => String(item || "").trim()).filter(Boolean) - : [], - }; -} - export async function GET(request: Request) { try { const session = await auth(); @@ -68,10 +15,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const taskId = String(searchParams.get("taskId") || "").trim(); if (!taskId) { - return NextResponse.json( - { success: false, error: "缺少 taskId 参数" }, - { status: 400 } - ); + return NextResponse.json({ success: false, error: "缺少 taskId 参数" }, { status: 400 }); } const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id }); @@ -100,105 +44,15 @@ export async function GET(request: Request) { success: false, error: error instanceof Error ? error.message : "查询任务失败", }, - { status: 500 } + { status: 500 }, ); } } export async function POST(request: Request) { - try { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json( - { success: false, error: "未登录" }, - { status: 401 } - ); - } - - const body: GenerateImageRequest = await request.json(); - const payload: CanvasGenerateImageRequest = { - ...toGenerateImagePayload(body), - ownerKey: session.user.id, - }; - if (!payload.prompt) { - return NextResponse.json( - { success: false, error: "请输入提示词" }, - { status: 400 } - ); - } - - const billingFeature = normalizeBillingFeature(body.billingFeature); - const pointsCharge = billingFeature - ? await (async () => { - const pointsConfig = await getPointsSystemConfig(); - return prepareFeaturePointsCharge({ - userId: session.user.id, - unitCost: getImageToolCreditCost(pointsConfig, billingFeature), - reason: - billingFeature === "multi-view" - ? "image_multi_view" - : "image_repaint", - modelId: "canvas-generate-image", - note: - billingFeature === "multi-view" - ? "图片多视角生成消耗积分" - : "图片重绘消耗积分", - pointsConfig, - }); - })() - : null; - if (pointsCharge && !pointsCharge.ok) { - return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); - } - - const asyncMode = body.async === true; - if (asyncMode) { - const task = await createCanvasImageTask({ - userId: session.user.id, - request: payload as unknown as Record, - }); - await enqueueCanvasImageTask(task.id); - const currentTask = await getCanvasImageTaskForUser({ - taskId: task.id, - userId: session.user.id, - }); - if (pointsCharge?.chargeable) { - try { - await pointsCharge.consume({ - note: - billingFeature === "multi-view" - ? `图片多视角生成消耗积分(${task.id})` - : `图片重绘消耗积分(${task.id})`, - }); - } catch (pointsError) { - console.error("[Points] Failed to consume canvas image task points:", pointsError); - } - } - return NextResponse.json({ - success: true, - taskId: task.id, - status: currentTask?.status || task.status, - queueStatus: currentTask?.queueStatus, - }); - } - - const result = await generateCanvasImage(payload); - if (pointsCharge?.chargeable) { - try { - await pointsCharge.consume(); - } catch (pointsError) { - console.error("[Points] Failed to consume canvas image points:", pointsError); - } - } - return NextResponse.json({ - success: true, - data: result, - }); - } catch (error) { - console.error("[Canvas Generate Image] Error:", error); - return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : "生成失败" }, - { status: 500 } - ); - } + const session = await auth(); + if (!session?.user?.id) + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + const body = await request.json().catch(() => ({})); + return executeCanvasImageResponse(body, session.user.id); } diff --git a/app/api/canvas/generation-tasks/route.ts b/app/api/canvas/generation-tasks/route.ts new file mode 100644 index 000000000..3c7181aa2 --- /dev/null +++ b/app/api/canvas/generation-tasks/route.ts @@ -0,0 +1,102 @@ +import { after, NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { z } from "zod"; +import { canvasTaskRequestSchema } from "@/lib/video-canvas/task-contract"; +import { + cancelCanvasGenerationTask, + createCanvasGenerationTask, + listCanvasGenerationTasks, + ownsTaskProject, +} from "@/lib/video-canvas/task-store"; +import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker"; +export const runtime = "nodejs"; +export const maxDuration = 900; + +async function owner(projectId: unknown) { + const session = await auth(); + if (!session?.user?.id) throw Object.assign(Error("未登录"), { status: 401 }); + if (!z.string().uuid().safeParse(projectId).success) + throw Object.assign(Error("画布 ID 无效"), { status: 400 }); + if (!(await ownsTaskProject(session.user.id, projectId as string))) + throw Object.assign(Error("画布不存在"), { status: 404 }); + return session.user.id; +} +const fail = (error: unknown) => + NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "任务操作失败" }, + { status: Number((error as any)?.status) || (error instanceof SyntaxError ? 400 : 500) }, + ); +const kick = (userId: string, projectId: string) => + after(() => + runCanvasGenerationQueue(userId, projectId).catch((error) => + console.error("[Canvas tasks]", error), + ), + ); +export async function POST(request: Request) { + try { + const text = await request.text(); + if (new TextEncoder().encode(text).byteLength > 262144) + throw Object.assign(Error("任务数据过大,请使用已上传的素材链接"), { status: 413 }); + const parsed = canvasTaskRequestSchema.safeParse(JSON.parse(text)); + if (!parsed.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); + return NextResponse.json({ success: true, task }, { status: 202 }); + } catch (error) { + return fail(error); + } +} +export async function GET(request: Request) { + try { + const projectId = new URL(request.url).searchParams.get("projectId") || ""; + const userId = await owner(projectId); + const tasks = await listCanvasGenerationTasks(userId, projectId); + if (tasks.some((task) => task.status === "pending")) kick(userId, projectId); + return NextResponse.json( + { success: true, tasks }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch (error) { + return fail(error); + } +} +export async function DELETE(request: Request) { + try { + const { projectId, taskId } = await request.json(); + const userId = await owner(projectId); + if (!z.string().uuid().safeParse(taskId).success) + throw Object.assign(Error("任务 ID 无效"), { status: 400 }); + const result = await cancelCanvasGenerationTask(userId, projectId, taskId); + if (!result.task) + return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 }); + if (!result.ok) + return NextResponse.json( + { + success: false, + error: + result.task.status === "running" + ? "任务已提交供应商,当前线路不支持中止。结果会继续保存,请勿重复生成。" + : "任务已结束或状态待核实,请先刷新任务状态。", + task: result.task, + }, + { status: 409 }, + ); + return NextResponse.json({ success: true, task: result.task }); + } catch (error) { + return fail(error); + } +} +export async function PATCH(request: Request) { + try { + const { projectId, taskId, nodeId } = await request.json(); + const userId = await owner(projectId); + if (!z.string().uuid().safeParse(taskId).success || typeof nodeId !== "string") + return NextResponse.json({ success: false }, { status: 400 }); + const { hideCanvasGenerationNode } = await import("@/lib/video-canvas/task-store"); + const success = await hideCanvasGenerationNode(userId, projectId, taskId, nodeId); + return NextResponse.json({ success }, { status: success ? 200 : 404 }); + } catch (error) { + return fail(error); + } +} diff --git a/app/api/canvas/workflow/analyze/route.ts b/app/api/canvas/workflow/analyze/route.ts index 627943d10..3d832b2be 100644 --- a/app/api/canvas/workflow/analyze/route.ts +++ b/app/api/canvas/workflow/analyze/route.ts @@ -1,786 +1,12 @@ -import { normalizeStoryScriptRows } from "@/lib/video-canvas/story-script"; -import { isCanvasLanguageModel, isCanvasVisionModel } from "@/lib/video-canvas/model-matching"; -import { generateText } from "ai"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; +import { executeWorkflowAnalysisResponse } from "@/lib/video-canvas/workflow-analysis-response"; import { NextResponse } from "next/server"; -import sharp from "sharp"; -import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; -import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; -import { inlineVisionImages } from "@/lib/ai/vision-inline"; -import { getAiModelAgentCodingModelId, isAiModelMultimodal } from "@/lib/db/ai-model-flags"; -import { - getAgentAiModelWithProvider, - getEnabledAiModelsWithProvider, -} from "@/lib/db/queries"; - export const runtime = "nodejs"; export const maxDuration = 180; - -const execFileAsync = promisify(execFile); -const VIDEO_ANALYSIS_FRAME_COUNT = 4; -const VIDEO_ANALYSIS_MAX_SIDE = 960; -const VIDEO_ANALYSIS_TIMEOUT_MS = 15000; - -const requestSchema = z.object({ - mode: z.enum(["video-analysis", "character-scene-extraction", "story-script", "agent-plan"]), - prompt: z.string().trim().optional(), - imageUrls: z.array(z.string().trim()).optional(), - videoUrls: z.array(z.string().trim()).optional(), - nodeTitle: z.string().trim().optional(), - preferredModelRef: z.string().trim().optional(), - model: z.string().trim().optional(), -}); - -function sanitizeUrls(value: unknown, limit = 6) { - if (!Array.isArray(value)) return []; - return value - .map((item) => String(item || "").trim()) - .filter((url) => /^https?:\/\//i.test(url)) - .slice(0, limit); -} - -function supportsVision(model: { - supportsVision?: boolean | null; - capabilities?: unknown; - defaultParams?: unknown; -}) { - return ( - Boolean(model.supportsVision) || - isAiModelMultimodal({ - capabilities: model.capabilities, - defaultParams: model.defaultParams, - }) - ); -} - -function extractJsonBlock>(text: string): T | null { - const matched = String(text || "").match(/\{[\s\S]*\}/); - if (!matched) return null; - try { - return JSON.parse(matched[0]) as T; - } catch { - return null; - } -} - -function normalizeText(value: unknown) { - return String(value || "").trim(); -} - -function shouldUseLocalWorkflowE2E(request: Request) { - const hostname = new URL(request.url).hostname; - return ( - process.env.CANVAS_E2E_LOCAL === "true" && - (hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1") - ); -} - -function normalizeStringList(value: unknown, limit = 8) { - if (!Array.isArray(value)) return []; - return Array.from( - new Set( - value - .map((item) => String(item || "").trim()) - .filter(Boolean) - .slice(0, limit) - ) - ); -} - -function normalizeShotIdeas(value: unknown) { - if (!Array.isArray(value)) return []; - return value - .map((item, index) => { - const raw = - item && typeof item === "object" && !Array.isArray(item) - ? (item as Record) - : {}; - const title = normalizeText(raw.title || `镜头 ${index + 1}`); - const description = normalizeText(raw.description || raw.prompt || raw.summary); - if (!title && !description) return null; - return { - title: title || `镜头 ${index + 1}`, - description, - }; - }) - .filter( - (item): item is { title: string; description: string } => Boolean(item) - ) - .slice(0, 6); -} - -function normalizeVideoAnalysisRows( - value: unknown, - framePreviews: string[], -) { - if (!Array.isArray(value)) return []; - return value - .map((item, index) => { - const raw = - item && typeof item === "object" && !Array.isArray(item) - ? (item as Record) - : {}; - const sceneDescription = normalizeText( - raw.sceneDescription || raw.scene || raw.title || raw.description, - ); - const narrative = normalizeText( - raw.narrative || raw.story || raw.storyContent || raw.narration || raw.summary, - ); - const shot = normalizeText(raw.shot || raw.shotNumber || raw.index || index + 1); - const startTime = normalizeText(raw.startTime || raw.start || raw.start_time); - const endTime = normalizeText(raw.endTime || raw.end || raw.end_time); - const duration = normalizeText(raw.duration || raw.durationSeconds || raw.duration_seconds); - const shotSize = normalizeText(raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale); - const cameraAngle = normalizeText(raw.cameraAngle || raw.angle || raw.camera_angle); - const cameraMovement = normalizeText( - raw.cameraMovement || raw.cameraMove || raw.camera_motion || raw.motionType, - ); - const focusDepth = normalizeText( - raw.focusDepth || raw.depthOfField || raw.focalDepth || raw.focus_depth, - ); - const lighting = normalizeText(raw.lighting || raw.light || raw.lightDesign); - const backgroundMusic = normalizeText( - raw.backgroundMusic || raw.music || raw.bgm, - ); - const voiceAndSfx = normalizeText( - raw.voiceAndSfx || raw.sound || raw.audio || raw.sfx, - ); - const imagePrompt = normalizeText( - raw.imagePrompt || raw.image_generation_prompt || raw.image, - ); - const motionPrompt = normalizeText( - raw.motionPrompt || raw.motion || raw.videoPrompt || raw.video_motion_prompt, - ); - const keyframeIndex = Number( - raw.keyframeIndex || raw.frameIndex || raw.key_frame_index || index + 1, - ); - if ( - !sceneDescription && - !narrative && - !backgroundMusic && - !voiceAndSfx && - !imagePrompt && - !motionPrompt && - !shotSize && - !cameraAngle && - !cameraMovement && - !focusDepth && - !lighting - ) { - return null; - } - const normalizedFrameIndex = - Number.isFinite(keyframeIndex) && keyframeIndex > 0 - ? Math.max(0, Math.min(framePreviews.length - 1, Math.round(keyframeIndex) - 1)) - : index % Math.max(framePreviews.length, 1); - return { - shot: shot || String(index + 1), - startTime: startTime || (index === 0 ? "0" : ""), - endTime, - duration, - sceneDescription: sceneDescription || `镜头 ${index + 1}`, - narrative: narrative || sceneDescription || `镜头 ${index + 1}`, - shotSize: shotSize || "中景至近景", - cameraAngle: cameraAngle || "平视", - cameraMovement: cameraMovement || "缓慢推镜头 (Slow Zoom In)", - focusDepth: focusDepth || "浅景深", - lighting: lighting || "冷调低沉照明,主体光线柔和。", - backgroundMusic: backgroundMusic || "无", - voiceAndSfx: voiceAndSfx || "无", - imagePrompt: imagePrompt || sceneDescription || "延续当前视频气质生成关键帧", - motionPrompt: motionPrompt || "保持镜头运动自然、连贯、符合前后节奏。", - keyframeUrl: framePreviews[normalizedFrameIndex] || "", - }; - }) - .filter( - ( - item, - ): item is { - shot: string; - startTime: string; - endTime: string; - duration: string; - sceneDescription: string; - narrative: string; - shotSize: string; - cameraAngle: string; - cameraMovement: string; - focusDepth: string; - lighting: string; - backgroundMusic: string; - voiceAndSfx: string; - imagePrompt: string; - motionPrompt: string; - keyframeUrl: string; - } => Boolean(item), - ) - .slice(0, 8); -} - -function normalizeCharacters(value: unknown) { - if (!Array.isArray(value)) return []; - return value - .map((item) => { - const raw = - item && typeof item === "object" && !Array.isArray(item) - ? (item as Record) - : {}; - const name = normalizeText(raw.name || raw.title); - const description = normalizeText(raw.description || raw.summary); - const prompt = normalizeText(raw.prompt); - if (!name && !description && !prompt) return null; - return { - name: name || "未命名角色", - description, - prompt, - look: normalizeText(raw.look) || undefined, - wardrobe: normalizeText(raw.wardrobe) || undefined, - signatureAction: normalizeText(raw.signatureAction) || undefined, - traits: normalizeStringList(raw.traits, 6), - }; - }) - .filter((item): item is NonNullable => Boolean(item)) - .slice(0, 8); -} - -function normalizeScenes(value: unknown) { - if (!Array.isArray(value)) return []; - return value - .map((item) => { - const raw = - item && typeof item === "object" && !Array.isArray(item) - ? (item as Record) - : {}; - const name = normalizeText(raw.name || raw.title); - const description = normalizeText(raw.description || raw.summary); - const prompt = normalizeText(raw.prompt); - if (!name && !description && !prompt) return null; - return { - name: name || "未命名场景", - description, - prompt, - mood: normalizeText(raw.mood) || undefined, - lighting: normalizeText(raw.lighting) || undefined, - props: normalizeStringList(raw.props, 6), - }; - }) - .filter((item): item is NonNullable => Boolean(item)) - .slice(0, 8); -} - -function buildVideoAnalysisPrompt(params: { - nodeTitle?: string; - prompt: string; - frameCount: number; - imageCount: number; - videoCount: number; -}) { - return [ - "你是视频画布工作流里的视频分析节点,需要为后续分镜和生成节点提供结构化输出。", - params.nodeTitle ? `当前节点:${params.nodeTitle}` : "", - `上游参考图数量:${params.imageCount}`, - `上游视频数量:${params.videoCount}`, - `已提供按时间顺序抽取的视频关键帧数量:${params.frameCount}`, - params.prompt ? `上游脚本/说明:\n${params.prompt}` : "上游没有额外脚本,请重点依据参考画面分析。", - "", - "请只输出 JSON,不要输出 markdown,不要输出解释。", - "JSON 结构如下:", - `{ - "summary": "一句话总结当前视频/镜头风格,不超过120字", - "highlights": ["3-5条镜头分析要点"], - "analysisRows": [ - { - "shot": "1", - "startTime": "0", - "endTime": "5", - "duration": "5", - "sceneDescription": "画面描述,说明主体、环境、动作、构图和色调", - "narrative": "叙事内容,说明这一镜头承担的剧情/情绪/信息作用", - "shotSize": "景别,例如中景至近景", - "cameraAngle": "摄影机角度,例如平视", - "cameraMovement": "摄影机运动,例如缓慢推镜头 (Slow Zoom In)", - "focusDepth": "焦距与景深,例如浅景深", - "lighting": "光线,例如冷调低沉照明,屏幕光作为侧逆光", - "backgroundMusic": "这一段适合的背景音乐氛围", - "voiceAndSfx": "人声/环境声/音效建议,没有就写无", - "imagePrompt": "后续生成关键帧时可直接使用的英文图像提示词", - "motionPrompt": "后续生成视频时可直接使用的英文运动提示词", - "keyframeIndex": 1 - } - ], - "shotIdeas": [ - { "title": "镜头 1", "description": "一句话描述镜头目标、景别或动作" } - ], - "visualMotifs": ["2-4个可复用的视觉母题"], - "recommendedNextSteps": ["2-4条建议的下游动作"] -}`, - "analysisRows 请按镜头时间顺序输出;短视频可以输出 1 行,复杂视频输出 2-6 行。", - "每行必须覆盖镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词和关键帧。", - "imagePrompt 和 motionPrompt 优先使用英文;其他字段使用中文;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。", - "如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。", - ] - .filter(Boolean) - .join("\n"); -} - -async function getVideoDurationSeconds(videoUrl: string) { - try { - const { stdout } = await execFileAsync( - "ffprobe", - [ - "-v", - "error", - "-show_entries", - "format=duration", - "-of", - "default=noprint_wrappers=1:nokey=1", - videoUrl, - ], - { - timeout: VIDEO_ANALYSIS_TIMEOUT_MS, - maxBuffer: 1024 * 1024, - }, - ); - const duration = Number.parseFloat(String(stdout || "").trim()); - return Number.isFinite(duration) && duration > 0 ? duration : null; - } catch { - return null; - } -} - -function buildVideoFrameTimestamps(durationSeconds: number | null, frameCount: number) { - const safeCount = Math.max(1, frameCount); - if (!durationSeconds || !Number.isFinite(durationSeconds) || durationSeconds <= 0.5) { - return Array.from({ length: safeCount }).map((_, index) => - Number((0.3 + index * 0.45).toFixed(2)), - ); - } - const start = Math.min(0.35, durationSeconds * 0.12); - const end = Math.max(start + 0.6, durationSeconds * 0.88); - if (safeCount === 1) return [Number(((start + end) / 2).toFixed(2))]; - const step = (end - start) / (safeCount - 1); - return Array.from({ length: safeCount }).map((_, index) => - Number((start + step * index).toFixed(2)), - ); -} - -async function downscaleVideoFrameForVision(inputBuffer: Buffer) { - return sharp(inputBuffer, { failOn: "none" }) - .rotate() - .resize({ - width: VIDEO_ANALYSIS_MAX_SIDE, - height: VIDEO_ANALYSIS_MAX_SIDE, - fit: "inside", - withoutEnlargement: true, - }) - .jpeg({ quality: 82, mozjpeg: true }) - .toBuffer(); -} - -async function extractVideoFrameDataUrls(videoUrl: string, frameCount = VIDEO_ANALYSIS_FRAME_COUNT) { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-analysis-")); - try { - const durationSeconds = await getVideoDurationSeconds(videoUrl); - const timestamps = buildVideoFrameTimestamps(durationSeconds, frameCount); - const frames = await Promise.all( - timestamps.map(async (timestamp, index) => { - const outputPath = path.join(tempDir, `frame-${index + 1}.jpg`); - await execFileAsync( - "ffmpeg", - [ - "-hide_banner", - "-loglevel", - "error", - "-ss", - String(timestamp), - "-i", - videoUrl, - "-frames:v", - "1", - "-q:v", - "4", - "-y", - outputPath, - ], - { - timeout: VIDEO_ANALYSIS_TIMEOUT_MS, - maxBuffer: 2 * 1024 * 1024, - }, - ); - const buffer = await readFile(outputPath); - const processed = await downscaleVideoFrameForVision(buffer); - return `data:image/jpeg;base64,${processed.toString("base64")}`; - }), - ); - return frames.filter(Boolean); - } catch (error) { - console.warn("[Canvas Workflow Analyze] extract video frames failed:", { - message: error instanceof Error ? error.message : String(error), - videoUrl, - }); - return []; - } finally { - await rm(tempDir, { force: true, recursive: true }).catch(() => undefined); - } -} - -function buildCharacterScenePrompt(params: { - nodeTitle?: string; - prompt: string; - imageCount: number; -}) { - return [ - "你是视频画布工作流里的角色场景提取节点,需要从脚本、设定和参考图里提取角色与场景资产。", - params.nodeTitle ? `当前节点:${params.nodeTitle}` : "", - `参考图数量:${params.imageCount}`, - params.prompt ? `输入内容:\n${params.prompt}` : "没有额外脚本,请根据参考图提取角色和场景线索。", - "", - "请只输出 JSON,不要输出 markdown,不要输出解释。", - "JSON 结构如下:", - `{ - "summary": "一句话总结当前故事世界与资产线索", - "characters": [ - { - "name": "角色名", - "description": "角色外观与身份简介", - "look": "外貌特征", - "wardrobe": "服装/道具", - "traits": ["性格关键词"], - "signatureAction": "代表动作", - "prompt": "可直接用于生成角色图/角色视频的提示词" - } - ], - "scenes": [ - { - "name": "场景名", - "description": "空间、布景和氛围简介", - "mood": "情绪与气氛", - "lighting": "布光特征", - "props": ["核心道具"], - "prompt": "可直接用于生成场景图/场景视频的提示词" - } - ], - "relationships": ["角色与场景或角色之间的关系"], - "recommendedNextSteps": ["建议连接的后续节点动作"] -}`, - "角色和场景都要尽量具体,避免空泛形容词。", - ] - .filter(Boolean) - .join("\n"); -} - -function buildAgentPlanPrompt(params: { - nodeTitle?: string; - prompt: string; - imageCount: number; - videoCount: number; -}) { - return [ - "你是视频画布里的自定义代理节点,需要把当前创作目标拆成可执行的工作流计划。", - params.nodeTitle ? `当前节点:${params.nodeTitle}` : "", - `参考图数量:${params.imageCount}`, - `视频数量:${params.videoCount}`, - params.prompt ? `上游需求:\n${params.prompt}` : "没有额外脚本,请基于当前上下文给出稳妥计划。", - "", - "请只输出 JSON,不要输出 markdown,不要输出解释。", - "JSON 结构如下:", - `{ - "summary": "一句话说明这个代理链的目标", - "agentPlan": ["3-6个执行步骤"], - "deliverables": ["2-4个预期交付物"], - "recommendedNodes": ["建议继续连接的节点类型或能力名"] -}`, - "步骤要偏工作流视角,而不是泛泛而谈。", - ] - .filter(Boolean) - .join("\n"); -} - -function buildStoryScriptPrompt(params: { - nodeTitle?: string; - prompt: string; - imageCount: number; - videoCount: number; -}) { - return [ - "你是视频画布里的故事脚本生成节点,需要把用户输入、参考图和参考视频整理成可直接用于后续分镜/生图/生视频的结构化脚本表。", - params.nodeTitle ? `当前节点:${params.nodeTitle}` : "", - `参考图数量:${params.imageCount}`, - `视频数量:${params.videoCount}`, - params.prompt ? `输入内容:\n${params.prompt}` : "没有额外脚本,请生成一个节奏清晰的短视频脚本。", - "", - "请只输出 JSON,不要输出 markdown,不要输出解释。", - "JSON 结构如下:", - `{ - "summary": "一句话概括故事主题、情绪和视频方向,不超过120字", - "analysisRows": [ - { - "shot": "01", - "duration": "5s", - "scene": "场景名或地点,如 盛唐·金銮殿·日", - "sceneDescription": "画面内容、人物动作和剧情推进,不要重复 scene 字段", - "shotSize": "景别,如中景、特写、全景、大远景", - "lighting": "光影氛围,如暖金主光带阴影,气氛紧张压抑", - "dialogue": "对白或旁白,没有就写 +", - "backgroundMusic": "背景音乐或情绪氛围,没有就写无", - "voiceAndSfx": "环境声、动作声或音效,没有就写无", - "cameraMovement": "运镜,如缓慢推镜头、固定、横移、上摇、环绕轨道跟拍", - "imagePrompt": "后续生成该镜头关键帧时可直接使用的中文图像提示词", - "motionPrompt": "后续生成视频时可直接使用的中文视频运动提示词" - } - ], - "assetCandidates": { - "style": "全局视觉风格,如 盛唐·国风二次元", - "characters": ["需要生成的角色资产名"], - "scenes": ["需要生成的场景资产名"], - "props": ["需要生成的道具资产名"] - }, - "shotIdeas": [ - { "title": "镜头 1", "description": "一句话描述镜头目标、景别或动作" } - ], - "recommendedNextSteps": ["2-4条建议的下游动作"] -}`, - "优先严格遵守用户指定的镜头数量和总时长;未指定时生成 6 个镜头,最多 18 个。镜头时长之和必须符合用户要求,8 秒短片不得扩写成几十秒。只生成实际需要的镜头,不重复凑数。", - "每行要覆盖竞品脚本表同等信息:镜号、时长、画面描述、景别、光影氛围、对白·旁白、音效、运镜、最终图像提示词和视频运动提示词。", - "画面描述中用 @角色名 或 @场景/道具名 标出可复用资产,例如 @沈昭昭、@金銮殿、@侍卫长刀。", - "imagePrompt 要像可直接生成分镜图的中文提示词,包含主体、环境、构图、光影、风格与一致性要求;motionPrompt 要按起始状态、动作过程、结束状态、音效组织。", - "assetCandidates 只提取脚本中确实出现的角色、场景和道具;产品视频没有人物时 characters 必须为空,禁止为凑数虚构角色。", - "每行要像专业视频画布脚本表:场景具体、画面可生成、声音可执行、镜头节奏明确,不要只写剧情摘要。", - ] - .filter(Boolean) - .join("\n"); -} - export async function POST(request: Request) { - try { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); - } - - const body = await request.json().catch(() => ({})); - const parsed = requestSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { success: false, error: "请求参数不合法" }, - { status: 400 } - ); - } - - const prompt = normalizeText(parsed.data.prompt); - const imageUrls = sanitizeUrls(parsed.data.imageUrls, 4); - const videoUrls = sanitizeUrls(parsed.data.videoUrls, 4); - const extractedVideoFrames = - parsed.data.mode === "video-analysis" && videoUrls.length > 0 - ? await extractVideoFrameDataUrls(videoUrls[0], VIDEO_ANALYSIS_FRAME_COUNT) - : []; - const preferredModelRef = normalizeText(parsed.data.preferredModelRef || parsed.data.model); - if (!prompt && imageUrls.length === 0 && videoUrls.length === 0) { - return NextResponse.json( - { success: false, error: "请至少提供脚本、图片或视频上下文" }, - { status: 400 } - ); - } - - - const [allModels, agentModel] = await Promise.all([ - getEnabledAiModelsWithProvider(false), - getAgentAiModelWithProvider(), - ]); - const hasVisionInputs = imageUrls.length > 0 || extractedVideoFrames.length > 0; - const compatibleModels = allModels.filter((item) => { - const descriptor = {...item.model, providerType: item.provider.type}; - return !item.model.isSubModel && item.model.isEnabled && - (hasVisionInputs ? isCanvasVisionModel(descriptor) : isCanvasLanguageModel(descriptor)); - }); - const requestedRef = preferredModelRef === "auto" ? "" : preferredModelRef; - const preferredModel = requestedRef ? compatibleModels.find( - item => item.model.id === requestedRef || item.model.modelId === requestedRef - ) : undefined; - if (requestedRef && !preferredModel) { - return NextResponse.json({ success: false, error: "所选模型不可用或不支持当前分析输入,请重新选择分析模型" }, { status: 400 }); - } - const selectedModel = preferredModel || compatibleModels.find(item => item.model.id === agentModel?.model.id) || compatibleModels[0]; - if (!selectedModel) { - return NextResponse.json({ success: false, error: hasVisionInputs ? "当前没有可用的视觉理解模型" : "当前没有可用的文字分析模型" }, { status: 400 }); - } - - const useVision = hasVisionInputs && supportsVision(selectedModel.model); - const codingModelId = getAiModelAgentCodingModelId(selectedModel.model); - const model = await getLanguageModelById(selectedModel.model.id, codingModelId); - if (!model) { - return NextResponse.json( - { success: false, error: "分析模型初始化失败" }, - { status: 500 } - ); - } - - const promptText = - parsed.data.mode === "video-analysis" - ? buildVideoAnalysisPrompt({ - frameCount: extractedVideoFrames.length, - nodeTitle: normalizeText(parsed.data.nodeTitle), - prompt, - imageCount: imageUrls.length, - videoCount: videoUrls.length, - }) - : parsed.data.mode === "character-scene-extraction" - ? buildCharacterScenePrompt({ - nodeTitle: normalizeText(parsed.data.nodeTitle), - prompt, - imageCount: imageUrls.length, - }) - : parsed.data.mode === "story-script" - ? buildStoryScriptPrompt({ - nodeTitle: normalizeText(parsed.data.nodeTitle), - prompt, - imageCount: imageUrls.length, - videoCount: videoUrls.length, - }) - : buildAgentPlanPrompt({ - nodeTitle: normalizeText(parsed.data.nodeTitle), - prompt, - imageCount: imageUrls.length, - videoCount: videoUrls.length, - }); - - const inlinedImageReferences = useVision - ? ( - await inlineVisionImages(imageUrls, { - maxImages: 4, - maxSide: 960, - timeoutMs: 12000, - retries: 1, - loggerPrefix: "[Canvas Workflow Analyze]", - }) - ).inlineDataUrls - : []; - const referenceImages = useVision - ? [...inlinedImageReferences, ...extractedVideoFrames].slice(0, 6) - : []; - - const { text, finishReason } = await generateText({ - model, - messages: [ - { - role: "user", - content: [ - { type: "text", text: promptText }, - ...referenceImages.map((image) => ({ type: "image" as const, image })), - ], - }, - ], - temperature: 0.2, - maxOutputTokens: parsed.data.mode === "story-script" ? 12000 : parsed.data.mode === "video-analysis" ? 2200 : 1600, - maxRetries: 0, - abortSignal: AbortSignal.any([request.signal, AbortSignal.timeout(150_000)]), - }); - - if (finishReason === "length" || !text.trim()) { - return NextResponse.json({ success: false, error: "分析结果不完整,请缩短输入或重新生成" }, { status: 502 }); - } - const parsedJson = extractJsonBlock>(text); - if (!parsedJson) { - return NextResponse.json({ success: false, error: "分析模型未返回完整结构化结果,请重新生成" }, { status: 502 }); - } - const summary = - normalizeText(parsedJson.summary) || - normalizeText(text).slice(0, 160); - - if (parsed.data.mode === "video-analysis") { - const analysisRows = normalizeVideoAnalysisRows( - parsedJson.analysisRows, - extractedVideoFrames, - ); - const shotIdeas = - normalizeShotIdeas(parsedJson.shotIdeas).length > 0 - ? normalizeShotIdeas(parsedJson.shotIdeas) - : analysisRows.map((row, index) => ({ - title: `镜头 ${index + 1}`, - description: row.sceneDescription, - })); - return NextResponse.json({ - success: true, - data: { - summary, - analysisRows, - framePreviews: extractedVideoFrames, - highlights: normalizeStringList(parsedJson.highlights, 6), - shotIdeas, - visualMotifs: normalizeStringList(parsedJson.visualMotifs, 6), - recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6), - model: selectedModel.model.displayName, - raw: text, - }, - }); - } - - if (parsed.data.mode === "character-scene-extraction") { - return NextResponse.json({ - success: true, - data: { - summary, - characters: normalizeCharacters(parsedJson.characters), - scenes: normalizeScenes(parsedJson.scenes), - relationships: normalizeStringList(parsedJson.relationships, 8), - recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6), - model: selectedModel.model.displayName, - raw: text, - }, - }); - } - - if (parsed.data.mode === "story-script") { - const analysisRows = normalizeStoryScriptRows( - parsedJson.analysisRows || parsedJson.storyRows || parsedJson.rows || parsedJson.shots, - ); - if (analysisRows.length === 0) { - return NextResponse.json({ success: false, error: "分析模型没有返回有效镜头,请重新生成" }, { status: 502 }); - } - return NextResponse.json({ - success: true, - data: { - summary, - analysisRows, - shotIdeas: - normalizeShotIdeas(parsedJson.shotIdeas).length > 0 - ? normalizeShotIdeas(parsedJson.shotIdeas) - : analysisRows.map((row, index) => ({ - title: `镜头 ${index + 1}`, - description: row.sceneDescription, - })), - recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6), - model: selectedModel.model.displayName, - raw: text, - }, - }); - } - - return NextResponse.json({ - success: true, - data: { - summary, - agentPlan: normalizeStringList(parsedJson.agentPlan, 8), - deliverables: normalizeStringList(parsedJson.deliverables, 6), - recommendedNodes: normalizeStringList(parsedJson.recommendedNodes, 6), - model: selectedModel.model.displayName, - raw: text, - }, - }); - } catch (error) { - console.error("[Canvas Workflow Analyze] Error:", error); - const timedOut = error instanceof Error && /timeout|timed out|abort/i.test(`${error.name} ${error.message}`); - return NextResponse.json( - { - success: false, - error: timedOut ? "分析超时,请稍后重试或选择其他分析模型" : error instanceof Error ? error.message : "工作流分析失败", - }, - { status: timedOut ? 504 : 500 } - ); - } + const session = await auth(); + if (!session?.user?.id) + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + const body = await request.json().catch(() => ({})); + return executeWorkflowAnalysisResponse(body, request.signal); } diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 8205be2a7..410d4d6f3 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -1,12 +1,16 @@ "use client"; import dynamic from "next/dynamic"; +import { signOut } from "next-auth/react"; +import { PromptTranslateButton } from "@/components/canvas/PromptTranslateButton"; +import { useVideoCanvasTasks } from "@/hooks/use-video-canvas-tasks"; +import { requireAnalysisResult } from "@/lib/video-canvas/task-contract"; const DirectorStudioDialog = dynamic( () => import("@/components/canvas/director/DirectorStudioDialog").then(module => module.DirectorStudioDialog), { ssr: false }, ); import { directorMotionPrompt, resizeDirectorMotion } from "@/lib/video-canvas/director-motion"; import type { DirectorSnapshot } from "@/lib/video-canvas/director-scene"; -import { filterCanvasModels, getCanvasVideoModes, getCanvasVideoParameters, type CanvasModelKind } from "@/lib/video-canvas/model-matching"; +import { chooseStoryVideoSettings, filterCanvasModels, getCanvasVideoModes, getCanvasVideoParameters, type CanvasModelKind } from "@/lib/video-canvas/model-matching"; import { ScanText, @@ -3496,7 +3500,7 @@ function getToolReferenceMediaSpec( title: string; width: number; } { - const title = template.title || "素材参考"; + const title = `${template.title || "素材参考"}(示例)`; if (template.kind === "audio") { return { action: "AUDIO_RESOURCE", @@ -4515,11 +4519,14 @@ function createPresetMediaNode({ x: number; y: number; }): CloneNode { + const isGenerator = /GENERATE/.test(action); + if (isGenerator) { generated = false; mediaUrl = ""; } + const isExample = !isGenerator && Boolean(mediaUrl); return { id: makeId(mediaType), kind: "media", - title, - subtitle: generated ? "生成结果" : mediaType === "audio" ? "音频资源" : "素材资源", + title: isExample ? `${title}(示例)` : title, + subtitle: isGenerator ? "待生成" : isExample ? "模板示例素材,可替换" : mediaType === "audio" ? "音频资源" : "素材资源", inputType: mediaType, x, y, @@ -4529,6 +4536,8 @@ function createPresetMediaNode({ draftValues: { action, generated, + isExample, + generationStatus: isGenerator ? "idle" : undefined, mediaType, mediaUrl: mediaUrl || "", params: prompt ? { prompt } : undefined, @@ -4566,7 +4575,7 @@ function createPresetScriptNode({ accent: "#f0d06b", draftValues: { action: isGenerator ? "SCRIPT_GENERATE" : "TEXT_RESOURCE", - content: isGenerator ? [] : [STORY_SCRIPT_TEXT], + content: [], params: isGenerator ? { count: 1, prompt: STORY_SCRIPT_PROMPT } : undefined, prompt: isGenerator ? STORY_SCRIPT_PROMPT : "", rows: isGenerator ? [] : undefined, @@ -4730,6 +4739,8 @@ export function LiblibVideoCanvasReplica({ }: LiblibVideoCanvasReplicaProps) { const router = useRouter(); const canvasRef = useRef(null); + const replaceFileInputRef=useRef(null); + const replaceFileNodeId=useRef(null); const connectionDragCleanupRef = useRef<(() => void) | null>(null); const connectionDragRef = useRef(null); const assetManagerResizeCleanupRef = useRef<(() => void) | null>(null); @@ -4799,6 +4810,7 @@ export function LiblibVideoCanvasReplica({ const initialProjectRef = useRef(initialProject); initialProjectRef.current = initialProject; const [loadedProject, setLoadedProject] = useState<{ id: string; revision: string } | null>(null); + const generationTasks = useVideoCanvasTasks(canvasId, loadedProject, setNodes, setEdges); const [, setShareEnabled] = useState(false); const [publishPublic, setPublishPublic] = useState(false); const [canvasCommand, setCanvasCommand] = useState(null); @@ -4900,7 +4912,8 @@ export function LiblibVideoCanvasReplica({ let active = true; const hydrateProject = (project: { canvasData?: unknown; title?: string | null }) => { const title = normalizeCanvasTitle(project.title); - const restored = restoreNodes(project.canvasData); + const restored = restoreNodes(project.canvasData).map(node=>getDraftString(node,"generationStatus")==="pending" && !node.draftValues?.taskRequestId && !node.draftValues?.generationTaskId && !getNodeParams(node).taskId ? + {...node,draftValues:{...node.draftValues,generationStatus:"failed",generationError:"旧版任务连接已中断,无法自动查询。请先核实已有结果,避免重复生成。"}}:node); const data = project.canvasData as { viewport?: typeof DEFAULT_VIEWPORT; selectedNodeIds?: string[] } | undefined; setProjectTitle(title); setDraftTitle(title); @@ -5044,38 +5057,15 @@ export function LiblibVideoCanvasReplica({ [addNodes] ); - useEffect(() => { - const timer = window.setInterval(() => { - setNodes((current) => { - let changed = false; - const nextNodes = current.map((node) => { - if (getDraftString(node, "generationStatus") !== "pending") return node; - const currentProgress = getDraftNumber(node, "generationProgress", 2); - const step = currentProgress < 24 ? 2 : currentProgress < 68 ? 1 : 0.35; - const nextProgress = Math.min(96, Number((currentProgress + step).toFixed(1))); - if (nextProgress === currentProgress) return node; - changed = true; - return { - ...node, - draftValues: { - ...(node.draftValues || {}), - generationProgress: nextProgress, - }, - }; - }); - return changed ? nextNodes : current; - }); - }, 850); - return () => window.clearInterval(timer); - }, []); - - const removeGenerationNode = useCallback((nodeId: string) => { + 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)); setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)); setSelectedNodeId((current) => (current === nodeId ? null : current)); setSelectedEdgeId(null); - }, []); + }, [nodes,generationTasks.dismiss]); const cancelGenerationNode = useCallback((nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); @@ -5279,47 +5269,14 @@ export function LiblibVideoCanvasReplica({ : item ) ); - try { - const response = await workflowAnalysisService.analyze({ + try {await generationTasks.submit({...node,kind:"text",draftValues:{...node.draftValues,prompt:finalPrompt,params:{...getNodeParams(node),functionKey,route,prompt:finalPrompt}}},"analysis",{ imageUrls, - mode: functionKey === "story-script" ? "story-script" : "video-analysis", + mode: functionKey === "story-script" ? "story-script" : functionKey === "image-to-prompt" ? "image-to-prompt" : "video-analysis", nodeTitle: node.title, preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, modelOverride), prompt: finalPrompt, videoUrls, - }); - if (!response.success) throw new Error(response.error || `${definition.label}生成失败`); - const content = - response.data?.raw || - response.data?.summary || - response.data?.agentPlan?.join("\n") || - response.data?.analysisRows?.map((row, index) => { - const scene = row.sceneDescription || `片段 ${index + 1}`; - return [scene, row.imagePrompt, row.motionPrompt].filter(Boolean).join("\n"); - }).join("\n\n") || - ""; - setNodes((current) => - current.map((item) => - item.id === nodeId - ? { - ...item, - kind: "text", - subtitle: "生成结果", - draftValues: { - ...(item.draftValues || {}), - content: content ? [content] : item.draftValues?.content, - generated: true, - generationProgress: 100, - generationStatus: "complete", - prompt: finalPrompt, - rows: response.data?.analysisRows || response.data?.shotIdeas || item.draftValues?.rows, - }, - } - : item - ) - ); - toast.success(`${definition.label || node.title}已生成`); - } catch (error) { + });} catch (error) { setNodes((current) => current.map((item) => item.id === nodeId @@ -5403,8 +5360,7 @@ export function LiblibVideoCanvasReplica({ : item ) ); - try { - const response = await videoGenerationService.generateVideo({ + try {await generationTasks.submit({...node,kind:"media",inputType:"video",draftValues:{...node.draftValues,prompt:finalPrompt,params:{...getNodeParams(node),functionKey,route,prompt:finalPrompt}}},"video",{ images: referenceImages, audioUrls: functionKey === "audio-to-video" ? audioUrls : undefined, mode: requestedMode, @@ -5412,36 +5368,7 @@ export function LiblibVideoCanvasReplica({ prompt: finalPrompt, ratio: "16:9", resolution: "720p", - }); - if (!response.success || !response.data?.url) { - throw new Error(response.error || `${definition.label}生成失败`); - } - setNodes((current) => - current.map((item) => - item.id === nodeId - ? { - ...item, - kind: "media", - inputType: "video", - subtitle: "生成结果", - draftValues: { - ...(item.draftValues || {}), - generated: true, - generationProgress: 100, - generationStatus: "complete", - mediaType: "video", - mediaUrl: response.data?.url, - nodeType: "video", - prompt: finalPrompt, - url: [response.data?.url], - videoMeta: response.data?.metadata || null, - }, - } - : item - ) - ); - toast.success(`${definition.label || node.title}已生成`); - } catch (error) { + });} catch (error) { setNodes((current) => current.map((item) => item.id === nodeId @@ -5534,8 +5461,7 @@ export function LiblibVideoCanvasReplica({ const response = await imageGenerationService.removeBackground(sourceUrl); generatedUrl = response.success ? response.data?.url || "" : ""; if (!generatedUrl && !response.success) throw new Error(response.error || "抠图处理失败"); - } else if (route === "canvas-generate-image" || definition.kind === "image-edit" || definition.kind === "utility") { - const response = await imageGenerationService.generateImage({ + } else if (route === "canvas-generate-image" || definition.kind === "image-edit" || definition.kind === "utility") {await generationTasks.submit({...node,width:resultWidth,height:resultHeight,kind:"media",inputType:"image",draftValues:{...node.draftValues,action:isPresetGridFunction(functionKey)?"IMAGE_GENERATE_NINE_GRID":"IMAGE_GENERATE",functionKey,prompt:finalPrompt,gridRows:gridLayout?.rows,gridColumns:gridLayout?.columns,gridPrompt:gridLayout?finalPrompt:undefined,nineGridComposite:Boolean(gridLayout),params:{...getNodeParams(node),functionKey,prompt:finalPrompt,sourceUrl,referenceImages:[sourceUrl,...extraReferenceImages]}}},"image",{ allowFailover: false, aspectRatio: isWideGeneration ? "16:9" : undefined, billingFeature: getImageGenerationBillingFeature(functionKey), @@ -5545,10 +5471,7 @@ export function LiblibVideoCanvasReplica({ promptExtend: !isWideGeneration, referenceImage: sourceUrl, referenceImages: extraReferenceImages, - }); - generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; - if (!generatedUrl && !response.success) throw new Error(response.error || `${definition.label}生成失败`); - } else { + });return;} else { throw new Error(`${definition.label || node.title}暂未配置可执行路由`); } @@ -5601,7 +5524,7 @@ export function LiblibVideoCanvasReplica({ toast.error(error instanceof Error ? error.message : `${definition.label}生成失败`); } }, - [edges, nodes, videoCanvasFunctionConfig] + [edges, nodes, videoCanvasFunctionConfig, generationTasks.submit] ); const addReferenceModulesForTool = useCallback( @@ -6727,8 +6650,7 @@ export function LiblibVideoCanvasReplica({ setConnectionMenu(null); setActivePanel(null); - if (shouldGenerate) { - const response = await imageGenerationService.generateImage({ + if (shouldGenerate) {await generationTasks.submit(pendingGridNode,"image",{ allowFailover: false, aspectRatio: "16:9", billingFeature: getImageGenerationBillingFeature(functionKey), @@ -6738,59 +6660,7 @@ export function LiblibVideoCanvasReplica({ promptExtend: false, referenceImage: sourceUrl, referenceImages: extraReferenceImages, - }); - const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; - if (generatedUrl) { - setNodes((current) => - current.map((node) => - node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending" - ? { - ...node, - draftValues: { - ...(node.draftValues || {}), - functionKey, - generated: true, - generationProgress: 100, - generationStatus: "complete", - gridPrompt: prompt, - mediaUrl: generatedUrl, - params: { - ...getNodeParams(node), - functionKey, - mode: "grid", - option: resultTitle, - prompt, - referenceImages: [sourceUrl, ...extraReferenceImages], - }, - prompt, - sourceNodeId: anchor.id, - sourceUrl, - toolbarOption: resultTitle, - url: [generatedUrl], - }, - } - : node - ) - ); - } else { - const errorMessage = response.error || `${definition.label}生成未返回结果`; - setNodes((current) => - current.map((node) => - node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending" - ? { - ...node, - draftValues: { - ...(node.draftValues || {}), - generationStatus: "failed", - generationError: errorMessage, - }, - } - : node - ) - ); - toast.error(`${errorMessage},已保留占位节点`); - } - } + },[{id:makeId("edge"),source:anchor.id,target:pendingGridNode.id}]);} toast.success(shouldGenerate ? `已创建${definition.label}生成中占位节点` : `已创建${definition.label}节点`); return; } @@ -6846,8 +6716,7 @@ export function LiblibVideoCanvasReplica({ setConnectionMenu(null); setActivePanel(null); - if (shouldGenerate) { - const response = await imageGenerationService.generateImage({ + if (shouldGenerate) {await generationTasks.submit(pendingNode,"image",{ allowFailover: false, aspectRatio: "16:9", billingFeature: getImageGenerationBillingFeature(functionKey), @@ -6857,58 +6726,7 @@ export function LiblibVideoCanvasReplica({ promptExtend: false, referenceImage: sourceUrl, referenceImages: extraReferenceImages, - }); - const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; - if (generatedUrl) { - setNodes((current) => - current.map((node) => - node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending" - ? { - ...node, - subtitle: "生成结果", - draftValues: { - ...(node.draftValues || {}), - functionKey, - generated: true, - generationProgress: 100, - generationStatus: "complete", - mediaUrl: generatedUrl, - params: { - ...getNodeParams(node), - functionKey, - option: resultTitle, - prompt, - referenceImages: [sourceUrl, ...extraReferenceImages], - }, - prompt, - sourceNodeId: anchor.id, - sourceUrl, - toolbarOption: resultTitle, - url: [generatedUrl], - }, - } - : node - ) - ); - } else { - const errorMessage = response.error || `${definition.label}生成未返回结果`; - setNodes((current) => - current.map((node) => - node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending" - ? { - ...node, - draftValues: { - ...(node.draftValues || {}), - generationStatus: "failed", - generationError: errorMessage, - }, - } - : node - ) - ); - toast.error(`${errorMessage},已保留占位节点`); - } - } + },[{id:makeId("edge"),source:anchor.id,target:pendingNode.id}]);} toast.success(shouldGenerate ? `已创建${definition.label}生成中占位节点` : `已创建${definition.label}节点`); return; } @@ -6984,8 +6802,7 @@ export function LiblibVideoCanvasReplica({ const response = await imageGenerationService.removeBackground(sourceUrl); generatedUrl = response.success ? response.data?.url || "" : ""; if (!generatedUrl && !response.success) toast.error(response.error || "抠图处理失败"); - } else if (definition.route === "canvas-generate-image") { - const response = await imageGenerationService.generateImage({ + } else if (definition.route === "canvas-generate-image") {await generationTasks.submit(pendingNode,"image",{ allowFailover: false, aspectRatio: "16:9", billingFeature: getImageGenerationBillingFeature(functionKey), @@ -6995,10 +6812,7 @@ export function LiblibVideoCanvasReplica({ promptExtend: false, referenceImage: sourceUrl, referenceImages: extraReferenceImages, - }); - generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; - if (!generatedUrl && !response.success) toast.error(response.error || `${definition.label}处理失败`); - } + },[{id:makeId("edge"),source:anchor.id,target:pendingNode.id}]);return;} if (generatedUrl) { setNodes((current) => @@ -7078,7 +6892,7 @@ export function LiblibVideoCanvasReplica({ setActivePanel(null); toast.success(`已创建「${title}」工作流节点`); }, - [nodes, videoCanvasFunctionConfig] + [nodes, videoCanvasFunctionConfig, generationTasks.submit] ); const updateTextResourceContent = useCallback((nodeId: string, content: string) => { @@ -7211,138 +7025,32 @@ export function LiblibVideoCanvasReplica({ ); }, []); - const pollLocalEditTaskForNode = useCallback(async (nodeId: string, taskId: string) => { - for (let attempt = 0; attempt < LOCAL_EDIT_MAX_POLL_ATTEMPTS; attempt += 1) { - await wait(attempt < 6 ? 1500 : 2500); - const response = await imageGenerationService.getImageTask(taskId); - if (!response.success || !response.data?.task) { - if (attempt > 4) { - setNodes((current) => - current.map((node) => - node.id === nodeId - ? { - ...node, - draftValues: { - ...(node.draftValues || {}), - generationError: response.error || "局部编辑任务查询失败", - generationStatus: "failed", - }, - } - : node - ) - ); - toast.error(response.error || "局部编辑任务查询失败"); - return; - } - continue; - } - - const { task } = response.data; - if (task.status === "succeeded") { - const first = task.result?.images?.[0]; - const generatedUrl = first?.originalUrl || first?.thumbnailUrl || ""; - if (!generatedUrl) { - setNodes((current) => - current.map((node) => - node.id === nodeId - ? { - ...node, - draftValues: { - ...(node.draftValues || {}), - generationError: "局部编辑已完成但未返回图片", - generationStatus: "failed", - }, - } - : node - ) - ); - toast.error("局部编辑已完成但未返回图片"); - return; - } - setNodes((current) => - current.map((node) => - node.id === nodeId - ? { - ...node, - subtitle: "生成结果", - draftValues: { - ...(node.draftValues || {}), - generated: true, - generationProgress: 100, - generationStatus: "complete", - localEditMode: false, - localEditRegions: [], - mediaUrl: generatedUrl, - modelRef: task.result?.modelRef, - params: { - ...getNodeParams(node), - model: task.result?.model, - taskId, - }, - url: [generatedUrl], - }, - } - : node - ) - ); - setSelectedNodeId(nodeId); - toast.success("局部编辑结果已生成"); - return; - } - - if (task.status === "failed") { - const errorMessage = task.error || "局部编辑生成失败"; - setNodes((current) => - current.map((node) => - node.id === nodeId - ? { - ...node, - subtitle: "生成失败", - draftValues: { - ...(node.draftValues || {}), - generationError: errorMessage, - generationStatus: "failed", - }, - } - : node - ) - ); - toast.error(errorMessage); - return; - } - - const progress = Math.min(96, 8 + attempt * 2); - setNodes((current) => - current.map((node) => - node.id === nodeId && getDraftString(node, "generationStatus") === "pending" - ? { - ...node, - draftValues: { - ...(node.draftValues || {}), - generationProgress: Math.max(getDraftNumber(node, "generationProgress", 2), progress), - }, - } - : node - ) - ); - } - - setNodes((current) => - current.map((node) => - node.id === nodeId - ? { - ...node, - draftValues: { - ...(node.draftValues || {}), - generationError: "局部编辑任务等待超时,请稍后重试", - generationStatus: "failed", - }, - } - : node - ) - ); - toast.error("局部编辑任务等待超时,请稍后重试"); - }, []); + const [localEditPollTick,setLocalEditPollTick]=useState(0); + const pollLocalEditTaskForNode=useCallback(async (_nodeId:string,_taskId:string)=>{setLocalEditPollTick(value=>value+1);},[]); + const localTaskNodes=useRef(nodes);localTaskNodes.current=nodes; + useEffect(()=>{ + if(!loadedProject) return; + let stopped=false,running=false; + const refresh=async()=>{ + if(running||stopped)return;running=true; + try {for(const node of localTaskNodes.current) { + const taskId=String(getNodeParams(node).taskId||""); + if(!taskId || getDraftString(node,"generationStatus")!=="pending" || node.draftValues?.generationTaskId)continue; + const response=await imageGenerationService.getImageTask(taskId); + if(stopped)break; + const task=response.data?.task;if(!response.success||!task)continue; + const url=task.result?.images?.[0]?.originalUrl||task.result?.images?.[0]?.thumbnailUrl; + setNodes(current=>current.map(item=>item.id===node.id&&String(getNodeParams(item).taskId)===taskId?{ + ...item,draftValues:{...item.draftValues,pendingLabel:task.status==="pending"?"排队中":"生成中", + ...(task.status==="succeeded"&&url?{generationStatus:"complete",generated:true,generationProgress:100,mediaUrl:url,url:[url],localEditMode:false,localEditRegions:[]}:{}), + ...(task.status==="failed"||task.status==="succeeded"&&!url?{generationStatus:"failed",generationError:task.error||"任务未返回图片"}:{})} + }:item)); + }} finally {running=false;} + }; + const wake=()=>{if(document.visibilityState==="visible")void refresh();}; + const timer=setInterval(()=>void refresh(),4000);window.addEventListener("online",wake);window.addEventListener("focus",wake);document.addEventListener("visibilitychange",wake);void refresh(); + return()=>{stopped=true;clearInterval(timer);window.removeEventListener("online",wake);window.removeEventListener("focus",wake);document.removeEventListener("visibilitychange",wake);}; + },[loadedProject,localEditPollTick]); const submitNodeLocalEdit = useCallback( async (nodeId: string, regions: LocalEditRegion[]) => { @@ -7889,120 +7597,17 @@ export function LiblibVideoCanvasReplica({ setActivePanel(null); toast.success("已提交图片生成"); - try { - if (!videoCanvasFunctionConfig.enabled || !definition?.enabled) { - throw new Error("视频画布功能未启用,请先在配置中开启"); - } - const response = await imageGenerationService.generateImage({ - ...canvasImageRequest( - { - ...payload.settings, - model: getFunctionModel( - videoCanvasFunctionConfig, - definition, - payload.settings.model, - ), - }, - [sourceUrl, ...extraReferenceImages], - isWideGeneration, - ), - allowFailover: false, - billingFeature: getImageGenerationBillingFeature(functionKey), - prompt: finalPrompt, - promptExtend: !isWideGeneration, - }); - const generatedUrls = canvasImageResultUrls(response); - const generatedUrl = generatedUrls[0]; - if (!generatedUrl) { - throw new Error(response.error || `${definition.label}生成失败`); - } - setNodes((current) => - current.map((item) => - item.id === resultNode.id - ? { - ...item, - kind: "media", - inputType: "image", - subtitle: "生成结果", - draftValues: { - ...(item.draftValues || {}), - action: "IMAGE_RESOURCE", - generated: true, - generationProgress: 100, - generationStatus: "complete", - mediaType: "image", - mediaUrl: generatedUrl, - nodeType: "image", - nineGridCells: gridLayout - ? Array.from({ length: gridLayout.rows * gridLayout.columns }).map( - (_, index) => getGridCellVisual(index), - ) - : item.draftValues?.nineGridCells, - nineGridComposite: gridLayout ? true : item.draftValues?.nineGridComposite, - prompt: finalPrompt, - sourceUrl, - url: [generatedUrl], - }, - } - : item, - ), - ); - const extraNodes = generatedUrls.slice(1).map((url, index) => ({ - ...resultNode, - id: makeId("image-result"), - title: `${resultTitle} ${index + 2}`, - y: resultNode.y + (index + 1) * (resultNode.height + 40), - kind: "media" as const, - inputType: "image" as const, - subtitle: "生成结果", - draftValues: { - ...resultNode.draftValues, - action: "IMAGE_RESOURCE", - generated: true, - generationProgress: 100, - generationStatus: "complete", - mediaType: "image", - nodeType: "image", - mediaUrl: url, - url: [url], - }, - })); - if (extraNodes.length) { - setNodes((current) => [...current, ...extraNodes]); - setEdges((current) => [ - ...current, - ...extraNodes.map((item) => ({ - id: makeId("edge"), - source: anchor.id, - target: item.id, - })), - ]); - } - toast.success(`已生成 ${generatedUrls.length} 张图片`); - } catch (error) { - setNodes((current) => - current.map((item) => - item.id === resultNode.id - ? { - ...item, - subtitle: "生成失败", - draftValues: { - ...(item.draftValues || {}), - generationError: - error instanceof Error ? error.message : `${definition.label}生成失败`, - generationStatus: "failed", - }, - } - : item, - ), - ); - toast.error(error instanceof Error ? error.message : `${definition.label}生成失败`); - } + if (!videoCanvasFunctionConfig.enabled || !definition.enabled) throw Error("该图片功能尚未启用"); + await generationTasks.submit(resultNode,"image",{ + ...canvasImageRequest({...payload.settings,model:getFunctionModel(videoCanvasFunctionConfig,definition,payload.settings.model)},[sourceUrl,...extraReferenceImages],isWideGeneration), + allowFailover:false,billingFeature:getImageGenerationBillingFeature(functionKey),prompt:finalPrompt,promptExtend:!isWideGeneration, + },[{id:makeId("edge"),source:anchor.id,target:resultNode.id},...referenceEdges]); + } finally { activeGenerationSubmissions.current.delete(nodeId); } }, - [nodes, videoCanvasFunctionConfig], + [nodes, videoCanvasFunctionConfig, generationTasks.submit], ); const persistVideoComposerSettings = useCallback((nodeId: string, settings: ComposerSettings, mode: VideoGenerationMode) => { @@ -8254,103 +7859,23 @@ export function LiblibVideoCanvasReplica({ ), ]); } - let completed = 0; - let failed = 0; - for (const jobId of [nodeId, ...siblings.map((item) => item.id)]) { - setNodes(current => current.map(item => item.id === jobId ? {...item,draftValues:{...item.draftValues,pendingLabel:"生成中",generationProgress:2}} : item)); - try { - const response = await videoGenerationService.generateVideo({ - duration, - images: referenceImages, - mode: requestedMode, - preferredModelRef, - prompt: finalPrompt, - generateAudio: savedSettings.audio !== "关闭", - ratio, - resolution, - }); - if (!response.success || !response.data?.url) { - throw new Error(response.error || "视频生成失败"); - } - setNodes((current) => - current.map((item) => - item.id === jobId - ? { - ...item, - kind: "media", - subtitle: "生成结果", - draftValues: { - ...clearGridSplitDraftValues(item.draftValues), - action: "VIDEO_GENERATE", - generated: true, - generationProgress: 100, - generationStatus: "complete", - mediaType: "video", - mediaUrl: response.data?.url, - nodeType: "video", - outputSizeLabel: `${formatVideoResolutionLabel(resolution)} · ${duration}s`, - params: { - ...getNodeParams(item), - duration, - effects: effectDescriptors, - roles: roleDescriptors, - mode: requestedMode, - magicMarks, - model: normalizeModelRef(preferredModelOverride) || undefined, - prompt: rawPrompt || finalPrompt, - ratio, - referenceImages, - resolution, - settings: savedSettings, - }, - poster: previewUrl, - prompt: rawPrompt || finalPrompt, - sourceUrl: previewUrl, - url: [response.data?.url], - videoMeta: response.data?.metadata || null, - }, - } - : item, - ), - ); - setSelectedNodeId(nodeId); - setCanvasCommand(null); - setNodeContextMenu(null); - setConnectionMenu(null); - setActivePanel(null); - completed += 1; - } catch (error) { - setNodes((current) => - current.map((item) => - item.id === jobId - ? { - ...item, - kind: "media", - subtitle: "生成失败", - draftValues: { - ...clearGridSplitDraftValues(item.draftValues), - generationStatus: "failed", - generationError: error instanceof Error ? error.message : "视频生成失败", - poster: previewUrl, - }, - } - : item, - ), - ); - failed += 1; - } + const primary = {...node,kind:"media" as const,draftValues:{...node.draftValues,action:"VIDEO_GENERATE",mediaUrl:"",url:[],mediaType:"video",poster:previewUrl, + params:{...getNodeParams(node),settings:savedSettings,mode:requestedMode,referenceImages,prompt:finalPrompt}}}; + for (const job of [primary,...siblings]) { + await generationTasks.submit(job,"video",{duration,images:referenceImages,mode:requestedMode,preferredModelRef,prompt:finalPrompt, + generateAudio:savedSettings.audio!=="关闭",ratio,resolution},incomingNodes.map(source=>({id:makeId("edge"),source:source.id,target:job.id}))); } - if (completed) toast.success(`视频生成完成:${completed}/${batchCount} 个`); - if (failed) toast.error(`${failed} 个视频生成失败,请查看对应节点;已成功结果保留`); + toast.success(`已提交 ${batchCount} 个视频任务,可离开页面后返回查看`); + } finally { activeGenerationSubmissions.current.delete(nodeId); } }, - [edges, nodes, videoCanvasFunctionConfig], + [edges, nodes, videoCanvasFunctionConfig, generationTasks.submit], ); const runStoryScriptToolbarAction = useCallback( - (nodeId: string, action: StoryToolbarAction) => { + async (nodeId: string, action: StoryToolbarAction) => { const anchor = nodes.find((item) => item.id === nodeId); if (!anchor) return; const rowKey = (row: StoryScriptRow) => row.shot || row.id; @@ -8385,90 +7910,12 @@ export function LiblibVideoCanvasReplica({ const selectedModelRef = normalizeModelRef(currentParams.model); const draftPrompt = getDraftString(anchor, "prompt") || STORY_SCRIPT_PROMPT; const prompt = composeFunctionPrompt(definition, `${draftPrompt}\n\n${textInputs}`.trim()); - setNodes((current) => - current.map((item) => - item.id === nodeId - ? { - ...item, - subtitle: "生成中", - draftValues: { - ...(item.draftValues || {}), - generated: false, - generationError: undefined, - generationProgress: 2, - generationStatus: "pending", - params: { - ...getNodeParams(item), - functionKey: "story-script", - imageUrls, - model: selectedModelRef || undefined, - prompt, - route: "canvas-workflow-analyze", - videoUrls, - }, - prompt, - }, - } - : item - ) - ); - setSelectedNodeId(nodeId); + if (!videoCanvasFunctionConfig.enabled || !definition.enabled) {toast.error("脚本生成功能尚未启用");return;} + void generationTasks.submit({...anchor,draftValues:{...anchor.draftValues,params:{...currentParams,functionKey:"story-script",prompt},prompt}},"analysis",{ + imageUrls,videoUrls,mode:"story-script",nodeTitle:anchor.title,prompt, + preferredModelRef:getFunctionModel(videoCanvasFunctionConfig,definition,selectedModelRef), + }).catch(error=>toast.error(error.message)); closeTransientUi(); - void (async () => { - try { - const response = await workflowAnalysisService.analyze({ - imageUrls, - mode: "story-script", - nodeTitle: anchor.title, - preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, selectedModelRef), - prompt, - videoUrls, - }); - if (!response.success) throw new Error(response.error || "故事脚本生成失败"); - const content = - response.data?.raw || - response.data?.agentPlan?.join("\n") || - response.data?.summary || - ""; - setNodes((current) => - current.map((item) => - item.id === nodeId - ? { - ...item, - subtitle: "生成结果", - draftValues: { - ...(item.draftValues || {}), - content: content ? [content] : item.draftValues?.content, - generated: true, - generationProgress: 100, - generationStatus: "complete", - prompt, - rows: response.data?.analysisRows || response.data?.shotIdeas || item.draftValues?.rows, - }, - } - : item - ) - ); - toast.success("故事脚本已重新生成"); - } catch (error) { - setNodes((current) => - current.map((item) => - item.id === nodeId - ? { - ...item, - subtitle: "生成失败", - draftValues: { - ...(item.draftValues || {}), - generationError: error instanceof Error ? error.message : "故事脚本生成失败", - generationStatus: "failed", - }, - } - : item - ) - ); - toast.error(error instanceof Error ? error.message : "故事脚本生成失败"); - } - })(); return; } @@ -8516,7 +7963,8 @@ export function LiblibVideoCanvasReplica({ ); if (action === "create-storyboard") { - if (rowsToCreate.length === 0) { + const retryable = existingStoryboards.filter(item=>!getNodeMediaUrl(item) && ["failed","idle", ""].includes(getDraftString(item,"generationStatus"))); + if (rowsToCreate.length === 0 && retryable.length === 0) { setSelectedNodeId(existingStoryboards[0]?.id || nodeId); closeTransientUi(); toast.info("当前脚本已经创建过分镜节点"); @@ -8586,7 +8034,17 @@ export function LiblibVideoCanvasReplica({ addNodes(nextNodes, nextEdges); setSelectedNodeId(nextNodes[0]?.id || anchor.id); closeTransientUi(); - toast.success(`已创建 ${nextNodes.length} 个分镜图片节点,选中节点后可生成`); + const definition=getConfiguredFunction(videoCanvasFunctionConfig,"image-repaint"); + if(!videoCanvasFunctionConfig.enabled||!definition.enabled) {toast.error("图片生成功能未启用,已保留分镜节点供稍后生成");return;} + for(const job of [...nextNodes,...retryable]) { + if(getNodeMediaUrl(job)) continue; + const params=getNodeParams(job); + const refs=Array.isArray(params.referenceImages)?params.referenceImages as string[]:[]; + try {await generationTasks.submit(job,"image",{prompt:String(params.prompt||""),model:getFunctionModel(videoCanvasFunctionConfig,definition), + referenceImage:refs[0],referenceImages:refs.slice(1),imageCount:1,aspectRatio:"16:9",imageSize:"1K",allowFailover:false,billingFeature:"repaint"},nextEdges.filter(edge=>edge.target===job.id));} + catch(error){toast.error(`${job.title}:${error instanceof Error?error.message:"提交失败"}`);} + } + toast.success("分镜图片已加入生成队列"); return; } @@ -8604,8 +8062,9 @@ export function LiblibVideoCanvasReplica({ .filter(Boolean) ); const rowsForVideo = rows.filter((row) => !existingVideoKeys.has(rowKey(row))); + const retryable=existingVideos.filter(item=>getDraftString(item,"generationStatus")==="failed" || getDraftString(item,"generationStatus")==="idle"); - if (rowsForVideo.length === 0) { + if (rowsForVideo.length === 0 && retryable.length === 0) { setSelectedNodeId(existingVideos[0]?.id || nodeId); closeTransientUi(); toast.info("当前脚本已经创建过视频生成器组"); @@ -8615,23 +8074,27 @@ export function LiblibVideoCanvasReplica({ const width = 622; const height = 350; const fallbackX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP + (existingStoryboards.length > 0 ? 472 : 0)); - const nextNodes = rowsForVideo.map((row) => { + let nextNodes:CloneNode[]; + try { nextNodes = rowsForVideo.map((row) => { const rowIndex = Math.max(0, rows.findIndex((item) => rowKey(item) === rowKey(row))); const storyboardNode = storyboardByKey.get(rowKey(row)); const referenceImage = storyboardNode ? getNodeReferenceMediaUrl(storyboardNode) : row.keyframeUrl; const previewUrl = referenceImage || row.keyframeUrl || ""; - const duration = Math.max(2,Math.min(15,Number.parseFloat(row.duration)||5)); - const model = getFunctionModel(videoCanvasFunctionConfig,getConfiguredFunction(videoCanvasFunctionConfig,"first-frame-video")) || "auto"; + const functionKey=referenceImage?"first-frame-video":"text-to-video"; + const definition=getConfiguredFunction(videoCanvasFunctionConfig,functionKey); + if(!videoCanvasFunctionConfig.enabled||!definition.enabled) throw Error("该视频生成功能尚未启用"); + const selected=chooseStoryVideoSettings(canvasModelOptions,Boolean(referenceImage),Number.parseFloat(row.duration)||5,getFunctionModel(videoCanvasFunctionConfig,definition)); + const {duration,model}=selected; const settings: ComposerSettings = { - audio: "开启", + audio: selected.generateAudio ? "开启" : "关闭", camera: "摄像机", count: "1个", duration: `${duration}s`, model, modelLabel: "", panorama: referenceImage ? "首帧生成视频" : "文生视频", - ratio: "16:9", - resolution: "720P", + ratio: selected.ratio, + resolution: selected.resolution, }; const prompt = buildStoryVideoPrompt(row); return { @@ -8647,26 +8110,26 @@ export function LiblibVideoCanvasReplica({ accent: "#8b96ff", draftValues: { action: "VIDEO_GENERATE", - functionKey: "first-frame-video", + functionKey, generated: false, generationProgress: 0, generationStatus: "idle", mediaType: "video", mediaUrl: "", nodeType: "video", - outputSizeLabel: `720P · ${duration}s`, + outputSizeLabel: `${selected.resolution} · ${duration}s`, params: { duration, - functionKey: "first-frame-video", + functionKey, imagePrompt: row.imagePrompt, mode: referenceImage ? "first-frame" : "text-to-video", model, modelLabel: "", motionPrompt: row.motionPrompt, prompt, - ratio: "16:9", + ratio: selected.ratio, referenceImages: referenceImage ? [referenceImage] : [], - resolution: "720p", + resolution: selected.resolution, sceneDescription: row.sceneDescription, settings, sourceShotDuration: row.duration, @@ -8685,6 +8148,7 @@ export function LiblibVideoCanvasReplica({ }, } satisfies CloneNode; }); + } catch(error) {toast.error(error instanceof Error?error.message:"模型规格无效");return;} const nextEdges = nextNodes.map((node) => ({ id: makeId("edge"), source: String(node.draftValues?.sourceStoryboardNodeId || anchor.id), @@ -8694,9 +8158,17 @@ export function LiblibVideoCanvasReplica({ addNodes(nextNodes, nextEdges); setSelectedNodeId(nextNodes[0]?.id || anchor.id); closeTransientUi(); - toast.success(`已创建 ${nextNodes.length} 个视频生成器组`); + for(const job of [...nextNodes,...retryable]) { + const params=getNodeParams(job); + const refs=Array.isArray(params.referenceImages)?params.referenceImages as string[]:[]; + try { + const selected=chooseStoryVideoSettings(canvasModelOptions,Boolean(refs.length),Number(params.duration)||5,String(params.model||"")); + await generationTasks.submit(job,"video",{prompt:String(params.prompt||""),images:refs,...selected,preferredModelRef:selected.model},nextEdges.filter(edge=>edge.target===job.id)); + } catch(error) {toast.error(`${job.title}:${error instanceof Error?error.message:"提交失败"}`);} + } + toast.success("分镜视频已加入队列;已完成镜头不会重复生成"); }, - [addNodes, edges, nodes, videoCanvasFunctionConfig] + [addNodes, edges, nodes, videoCanvasFunctionConfig, canvasModelOptions, generationTasks.submit] ); const updateVideoClipRange = useCallback((nodeId: string, nextRange: VideoClipRange) => { @@ -9339,7 +8811,7 @@ export function LiblibVideoCanvasReplica({ : item ) ); - const response = await workflowAnalysisService.analyze({ + await generationTasks.submit(node,"analysis",{ imageUrls, mode: "story-script", nodeTitle: node.title, @@ -9347,31 +8819,6 @@ export function LiblibVideoCanvasReplica({ prompt, videoUrls, }); - if (!response.success) throw new Error(response.error || "故事脚本生成失败"); - const content = - response.data?.raw || - response.data?.agentPlan?.join("\n") || - response.data?.summary || - ""; - setNodes((current) => - current.map((item) => - item.id === nodeId - ? { - ...item, - draftValues: { - ...(item.draftValues || {}), - content: content ? [content] : item.draftValues?.content, - generated: true, - generationProgress: 100, - generationStatus: "complete", - prompt, - rows: response.data?.analysisRows || response.data?.shotIdeas || item.draftValues?.rows, - }, - } - : item - ) - ); - toast.success("故事脚本已生成"); return; } @@ -9444,7 +8891,7 @@ export function LiblibVideoCanvasReplica({ : item ) ); - const response = await videoGenerationService.generateVideo({ + await generationTasks.submit({...node,inputType:"video",draftValues:{...node.draftValues,mediaType:"video",params:{...getNodeParams(node),settings:savedSettings,prompt,mode:requestedMode,referenceImages}}},"video",{ duration, images: referenceImages, audioUrls: mode === "audio-to-video" ? audioUrls : undefined, @@ -9455,49 +8902,7 @@ export function LiblibVideoCanvasReplica({ ratio, resolution, }); - if (!response.success || !response.data?.url) { - throw new Error(response.error || `${definition.label}生成失败`); - } - setNodes((current) => - current.map((item) => - item.id === nodeId - ? { - ...item, - kind: "media", - subtitle: "生成结果", - draftValues: { - ...(item.draftValues || {}), - generated: true, - generationProgress: 100, - generationStatus: "complete", - mediaType: "video", - mediaUrl: response.data?.url, - nodeType: "video", - outputSizeLabel: `${formatVideoResolutionLabel(resolution)} · ${duration}s`, - params: { - ...getNodeParams(item), - audioUrls, - duration, - functionKey: definition.key, - mode: requestedMode, - model: normalizeModelRef(preferredModelOverride) || undefined, - prompt, - ratio, - referenceImages, - resolution, - settings: savedSettings, - }, - poster: previewUrl, - prompt, - sourceUrl: previewUrl, - url: [response.data?.url], - videoMeta: response.data?.metadata || null, - }, - } - : item - ) - ); - toast.success(`${definition.label}已生成`); + } catch (error) { setNodes((current) => current.map((item) => @@ -9517,7 +8922,7 @@ export function LiblibVideoCanvasReplica({ toast.error(error instanceof Error ? error.message : "生成失败"); } }, - [edges, nodes, videoCanvasFunctionConfig] + [edges, nodes, videoCanvasFunctionConfig, generationTasks.submit] ); const createFreeNode = useCallback( @@ -9560,6 +8965,7 @@ export function LiblibVideoCanvasReplica({ ...node, id: makeId("copy"), title: `${node.title} - 副本`, + draftValues: {...node.draftValues,taskRequestId:undefined,generationTaskId:undefined,appliedTaskStatus:undefined,generationStatus:getDraftString(node,"generationStatus")==="pending"?"idle":node.draftValues?.generationStatus}, x: node.x, y: node.y + node.height + 60, }; @@ -9671,7 +9077,7 @@ export function LiblibVideoCanvasReplica({ generatedUrl = response.success ? response.data?.url || "" : ""; if (!generatedUrl && !response.success) throw new Error(response.error || "图片增强失败"); } else { - const response = await imageGenerationService.generateImage({ + await generationTasks.submit({...node,draftValues:{...node.draftValues,functionKey,params:{...params,functionKey,prompt},prompt}},"image",{ allowFailover: false, aspectRatio: isWideImageGenerationFunction(functionKey) ? "16:9" : String(paramsSettings.ratio || params.ratio || "auto"), imageSize: String(paramsSettings.resolution || params.imageSize || "1K"), @@ -9683,8 +9089,7 @@ export function LiblibVideoCanvasReplica({ referenceImage: sourceUrl || undefined, referenceImages: extraReferenceImages, }); - generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; - if (!generatedUrl && !response.success) throw new Error(response.error || "图片生成失败"); + return; } if (!generatedUrl) throw new Error("图片生成未返回结果"); @@ -9738,45 +9143,16 @@ export function LiblibVideoCanvasReplica({ } if (node.inputType === "text" || /SCRIPT_GENERATE|TEXT_GENERATE/.test(action)) { - const definition = getConfiguredFunction( - videoCanvasFunctionConfig, - imageUrls.length > 0 || videoUrls.length > 0 ? "image-to-prompt" : "story-script", - ); - const prompt = composeFunctionPrompt(definition, rawPrompt || STORY_SCRIPT_PROMPT); - const response = await workflowAnalysisService.analyze({ - imageUrls, - mode: definition.key === "story-script" ? "story-script" : "agent-plan", - nodeTitle: node.title, - prompt, - preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef), - videoUrls, + const savedKey=String(params.functionKey || node.draftValues?.functionKey || ""); + const isScript=/SCRIPT_GENERATE/.test(action) || node.kind === "script" || getPresetMode(node)==="story-script" || savedKey==="story-script"; + const key=isScript ? "story-script" : savedKey || (imageUrls.length || videoUrls.length ? "image-to-prompt":"story-script"); + const definition=getConfiguredFunction(videoCanvasFunctionConfig,key); + if(!videoCanvasFunctionConfig.enabled||!definition.enabled) throw Error("该分析功能尚未启用"); + const prompt=composeFunctionPrompt(definition,[rawPrompt||STORY_SCRIPT_PROMPT,...incomingNodes.flatMap(item=>Array.isArray(item.draftValues?.content)?item.draftValues.content:[])].join("\n\n")); + await generationTasks.submit({...node,draftValues:{...node.draftValues,prompt,params:{...params,functionKey:key,prompt}}},"analysis",{ + imageUrls,videoUrls,prompt,nodeTitle:node.title,mode:key==="story-script"?"story-script":"agent-plan", + preferredModelRef:getFunctionModel(videoCanvasFunctionConfig,definition,nodeModelRef), }); - if (!response.success) throw new Error(response.error || "多模态分析失败"); - const content = - response.data?.raw || - response.data?.agentPlan?.join("\n") || - response.data?.summary || - response.data?.recommendedNextSteps?.join("\n") || - ""; - setNodes((current) => - current.map((item) => - item.id === nodeId - ? { - ...item, - draftValues: { - ...(item.draftValues || {}), - content: content ? [content] : item.draftValues?.content, - generated: true, - generationStatus: "complete", - generationProgress: 100, - prompt, - rows: response.data?.analysisRows || response.data?.shotIdeas || item.draftValues?.rows, - }, - } - : item - ) - ); - toast.success(`${node.title} 已生成文本结果`); return; } @@ -9785,9 +9161,12 @@ export function LiblibVideoCanvasReplica({ setNodes(current=>current.map(item=>item.id===nodeId?{...item,draftValues:{...item.draftValues,generationStatus:"failed",generationError:error instanceof Error ? error.message : "生成失败"}}:item)); toast.error(error instanceof Error ? error.message : `${node.title} 生成失败`); } finally {if(!delegatesVideo) activeGenerationSubmissions.current.delete(nodeId);} - }, [edges, nodes, submitVideoTargetComposer, videoCanvasFunctionConfig]); + }, [edges, nodes, submitVideoTargetComposer, videoCanvasFunctionConfig, generationTasks.submit]); - const deleteNode = useCallback((nodeId: string, options?: { silent?: boolean }) => { + const deleteNode = useCallback(async (nodeId: string, options?: { silent?: boolean }) => { + 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)); setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)); setSelectedNodeId((current) => (current === nodeId ? null : current)); @@ -9798,7 +9177,7 @@ export function LiblibVideoCanvasReplica({ if (!options?.silent) { toast.success("节点已删除"); } - }, []); + }, [nodes,generationTasks.dismiss,cancelGenerationNode]); const deleteEdge = useCallback((edgeId: string) => { setEdges((current) => current.filter((edge) => edge.id !== edgeId)); @@ -10804,10 +10183,27 @@ export function LiblibVideoCanvasReplica({ /> ) : null} + { + const file=event.target.files?.[0],nodeId=replaceFileNodeId.current;event.target.value=""; + if(!file||!nodeId)return; + try { + const mediaType=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":file.type.startsWith("audio/")?"audio":null; + if(!mediaType)throw Error("请选择图片、视频或音频文件"); + let url="",duration:number|undefined; + if(mediaType==="image") {const result=await imageGenerationService.uploadImage(file);if(!result.success||!result.data?.url)throw Error(result.error||"上传失败");url=result.data.url;} + else {const form=new FormData();form.append("file",file);const response=await fetch("/api/canvas/media/upload",{method:"POST",body:form});const result=await response.json();if(!response.ok||!result.data?.url)throw Error(result.error||"上传失败");url=result.data.url;duration=result.data.duration;} + setNodes(current=>current.map(node=>node.id===nodeId?{...node,kind:"media",inputType:mediaType,title:file.name,draftValues:{...node.draftValues,mediaType,mediaUrl:url,url:[url],isExample:false,generated:false,generationStatus:"idle",durationSeconds:duration}}:node)); + }catch(error){toast.error(error instanceof Error?error.message:"上传失败");} + }}/> {cancelGenerationConfirmNodeId ? ( setCancelGenerationConfirmNodeId(null)} - onConfirm={() => setCancelGenerationConfirmNodeId(null)} + onConfirm={async () => { + const taskId = getDraftString(nodes.find(node=>node.id===cancelGenerationConfirmNodeId),"generationTaskId"); + if (!taskId) {toast.info("该任务已在执行或正在确认提交,当前无法中止;完成后会保存结果。");setCancelGenerationConfirmNodeId(null);return;} + try {await generationTasks.cancel(taskId);toast.success("已取消排队任务");}catch(error){toast.error(error instanceof Error?error.message:"取消失败");} + setCancelGenerationConfirmNodeId(null); + }} /> ) : null} @@ -10833,6 +10229,7 @@ export function LiblibVideoCanvasReplica({ selected={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();}}} onClearGridSplit={() => dismissNodeGridSplit(node.id)} onClearGridSplitSelection={() => clearNodeGridSplitSelection(node.id)} onClearLocalEditRegions={() => clearNodeLocalEditRegions(node.id)} @@ -11617,7 +11014,7 @@ export function LiblibVideoCanvasReplica({ ) : null} {activePanel === "notifications" ? ( - setActivePanel(null)} /> + setActivePanel(null)} tasks={generationTasks.tasks.filter(task=>!task.request.hiddenNodeIds?.includes(task.request.node.id))} onLocate={locateCanvasNode} onRefresh={generationTasks.refresh}/> ) : null} {activePanel === "account" ? ( @@ -13807,7 +13204,7 @@ function VideoClipEditorBar({ +
{["文生视频", "全能参考", "图生视频", "首尾帧", "图片参考"].map((tab, index) => (
@@ -16035,7 +15432,7 @@ function ScriptGeneratorPanel() { ))} +
{title} {thumbnailUrl ? ( @@ -16243,7 +15640,7 @@ function PresetComposerPanel({ setMenu((current) => current === "model" ? null : "model")} /> setMenu((current) => current === "ratio" ? null : "ratio")} /> setMenu((current) => current === "resolution" ? null : "resolution")} /> - toast.info("镜头配置接口待接入。")} /> + toast.info("当前仅支持单次生成 1 个结果。")} />
+
{[ ["style", "◇", "风格"], @@ -18271,22 +17668,24 @@ function ComposerControl({ label, muted = false, onClick, + disabled=false, }: { active?: boolean; dropdown?: boolean; icon?: string; label: string; muted?: boolean; - onClick: () => void; + onClick?: () => void; + disabled?: boolean; }) { return ( -
@@ -21734,7 +21135,7 @@ function PresetMediaCanvasNode({ )} -