diff --git a/web/package.json b/web/package.json index a530a1691..3e8ce83c9 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/agent-prompt-cache.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/client-id.test.ts", + "test": "bun test test/agent-prompt-cache.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/canvas-resource-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/client-id.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/components/canvas/canvas-node-toolbar.tsx b/web/src/components/canvas/canvas-node-toolbar.tsx index dffd7da6d..2bc6f98de 100644 --- a/web/src/components/canvas/canvas-node-toolbar.tsx +++ b/web/src/components/canvas/canvas-node-toolbar.tsx @@ -42,9 +42,13 @@ type CanvasNodeToolbarProps = { onAngle: (node: CanvasNodeData) => void; onViewImage: (node: CanvasNodeData) => void; onExtractVideoLastFrame: (node: CanvasNodeData) => void; + onExtractAudioFromVideo: (node: CanvasNodeData) => void; + onTrimVideoRegenerate: (node: CanvasNodeData) => void; onSubtitles: (node: CanvasNodeData) => void; onTimeline: (node: CanvasNodeData) => void; extractingVideoFrame: boolean; + extractingAudio: boolean; + trimmingVideo: boolean; onReversePrompt: (node: CanvasNodeData) => void; onRetry: (node: CanvasNodeData) => void; onToggleFreeResize: (node: CanvasNodeData) => void; @@ -105,9 +109,13 @@ export function CanvasNodeToolbar({ onAngle, onViewImage, onExtractVideoLastFrame, + onExtractAudioFromVideo, + onTrimVideoRegenerate, onSubtitles, onTimeline, extractingVideoFrame, + extractingAudio, + trimmingVideo, onReversePrompt, onRetry, onToggleFreeResize, @@ -243,7 +251,7 @@ export function CanvasNodeToolbar({ onNodeToggleDialog: onToggleDialog, onNodeAnnotate: onAnnotate, onNodeGenerateImage: onGenerateImage, onNodeUpload: onUpload, onNodeDownload: onDownload, onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop, onNodeSplit: onSplit, onNodeUpscale: onUpscale, onNodeSuperResolve: onSuperResolve, onNodeAngle: onAngle, onNodeViewImage: onViewImage, - onNodeExtractVideoLastFrame: onExtractVideoLastFrame, onNodeReversePrompt: onReversePrompt, onNodeToggleFreeResize: onToggleFreeResize, + onNodeExtractVideoLastFrame: onExtractVideoLastFrame, onNodeExtractAudioFromVideo: onExtractAudioFromVideo, onNodeTrimVideoRegenerate: onTrimVideoRegenerate, onNodeReversePrompt: onReversePrompt, onNodeToggleFreeResize: onToggleFreeResize, onNodeSubtitles: onSubtitles, onNodeTimeline: onTimeline, onNodeToggleLocked: onToggleLocked, onNodeCopyPrompt: copyImagePrompt, } as Partial as ToolbarHandlers; @@ -259,6 +267,8 @@ export function CanvasNodeToolbar({ node, nodeMetadata: node.metadata, extractingVideoFrame, + extractingAudio, + trimmingVideo, mergingVideos: false, addPanelOpen: false, appearancePanelOpen: false, diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx index f9e2ad7ee..0805bb523 100644 --- a/web/src/components/canvas/canvas-toolbar.tsx +++ b/web/src/components/canvas/canvas-toolbar.tsx @@ -139,7 +139,7 @@ export function CanvasToolbar({ onNodeInfo: () => {}, onNodeDelete: () => {}, onNodeRetry: () => {}, onNodeEditText: () => {}, onNodeDecreaseFont: () => {}, onNodeIncreaseFont: () => {}, onNodeToggleDialog: () => {}, onNodeAnnotate: () => {}, onNodeGenerateImage: () => {}, onNodeUpload: () => {}, onNodeDownload: () => {}, onNodeSaveAsset: () => {}, onNodeMaskEdit: () => {}, onNodeEmotion: () => {}, onNodePortraitTexture: () => {}, onNodeCrop: () => {}, onNodeSplit: () => {}, onNodeUpscale: () => {}, - onNodeSuperResolve: () => {}, onNodeAngle: () => {}, onNodeViewImage: () => {}, onNodeExtractVideoLastFrame: () => {}, onNodeSubtitles: () => {}, onNodeTimeline: () => {}, onNodeReversePrompt: () => {}, + onNodeSuperResolve: () => {}, onNodeAngle: () => {}, onNodeViewImage: () => {}, onNodeExtractVideoLastFrame: () => {}, onNodeExtractAudioFromVideo: () => {}, onNodeTrimVideoRegenerate: () => {}, onNodeSubtitles: () => {}, onNodeTimeline: () => {}, onNodeReversePrompt: () => {}, onNodeToggleFreeResize: () => {}, onNodeToggleLocked: () => {}, onNodeCopyPrompt: () => {}, } as ToolbarHandlers; @@ -153,6 +153,8 @@ export function CanvasToolbar({ canUndo, canRedo, extractingVideoFrame: false, + extractingAudio: false, + trimmingVideo: false, mergingVideos: false, addPanelOpen: addOpen, appearancePanelOpen: appearanceOpen, diff --git a/web/src/components/canvas/canvas-video-segment-dialog.tsx b/web/src/components/canvas/canvas-video-segment-dialog.tsx new file mode 100644 index 000000000..13f2d91aa --- /dev/null +++ b/web/src/components/canvas/canvas-video-segment-dialog.tsx @@ -0,0 +1,422 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { App, Button, Input, InputNumber, Modal, Select } from "antd"; +import { AudioLines, Check, ListVideo, Plus, Scissors, SkipBack, SkipForward, Trash2 } from "lucide-react"; +import { nanoid } from "nanoid"; + +import { ModelPicker } from "@/components/model-picker"; +import { canvasThemes } from "@/lib/canvas-theme"; +import { buildTimelineImportSegments, type CanvasTimelineSegmentItem } from "@/lib/canvas/canvas-video-timeline-segments"; +import { listVideoReferenceModels } from "@/lib/canvas/canvas-video-regeneration"; +import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; +import { navigateToSettings } from "@/lib/settings-navigation"; +import { useThemeStore } from "@/stores/use-theme-store"; +import { resolveMediaUrl } from "@/services/file-storage"; +import { cacheResourceObjectUrl } from "@/services/resource-blob-cache"; +import { resourceIdFromStorageKey } from "@/services/api/resources"; +import { modelDisplayName, type AiConfig } from "@/stores/use-config-store"; +import { type CanvasConnection, type CanvasNodeData, type CanvasVideoEditOperation } from "@/types/canvas"; +import type { TimelineProject } from "@/types/timeline"; + +export type CanvasVideoSegmentItem = CanvasTimelineSegmentItem; + +export type CanvasVideoSegmentParams = { + mode: "audio" | "video"; + startMs: number; + endMs: number; + prompt?: string; + segments?: CanvasVideoSegmentItem[]; + model?: string; + operation?: CanvasVideoEditOperation; +}; + +type CanvasVideoSegmentDialogProps = { + node: CanvasNodeData; + nodes: CanvasNodeData[]; + connections: CanvasConnection[]; + open: boolean; + mode: "audio" | "video"; + config: AiConfig; + timeline?: TimelineProject | null; + onClose: () => void; + onConfirm: (params: CanvasVideoSegmentParams) => void; +}; + +const MIN_SEGMENT_MS = 100; + +export function CanvasVideoSegmentDialog({ node, nodes, connections, open, mode, config, timeline, onClose, onConfirm }: CanvasVideoSegmentDialogProps) { + const { message } = App.useApp(); + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const videoRef = useRef(null); + const segmentsSeededRef = useRef(false); + const [videoUrl, setVideoUrl] = useState(""); + const [videoError, setVideoError] = useState(false); + const [durationMs, setDurationMs] = useState(0); + const [startSec, setStartSec] = useState(0); + const [endSec, setEndSec] = useState(0); + const [prompt, setPrompt] = useState(""); + const [segments, setSegments] = useState([]); + const [model, setModel] = useState(""); + const [operation, setOperation] = useState("extend"); + const eligibleModels = useMemo(() => listVideoReferenceModels(config), [config]); + + // 打开弹窗时解析视频地址,读取时长并初始化起止时间。 + useEffect(() => { + if (!open) return; + let cancelled = false; + setVideoUrl(""); + setVideoError(false); + setDurationMs(0); + setStartSec(0); + setEndSec(0); + setPrompt(""); + setSegments([]); + segmentsSeededRef.current = false; + const defaultModel = config.videoModel || config.model || ""; + const initialModel = eligibleModels.includes(defaultModel) ? defaultModel : eligibleModels[0] || defaultModel; + setModel(initialModel); + const profile = initialModel ? modelCapabilityConfigFor(config, initialModel).video : undefined; + setOperation(profile?.operations.includes("extend") ? "extend" : (profile?.operations[0] as CanvasVideoEditOperation | undefined) || "extend"); + const storageKey = node.metadata?.storageKey || ""; + const fallback = node.metadata?.content || ""; + const applyUrl = (url: string) => { + if (!cancelled) setVideoUrl(url); + }; + if (resourceIdFromStorageKey(storageKey)) { + void cacheResourceObjectUrl(storageKey) + .then((cached) => { + if (cancelled) return; + if (cached) setVideoUrl(cached); + else void resolveMediaUrl(storageKey, fallback).then(applyUrl); + }) + .catch(() => { + if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl); + }); + } else { + void resolveMediaUrl(storageKey, fallback).then(applyUrl); + } + return () => { + cancelled = true; + }; + }, [config, eligibleModels, node, open]); + + // 视频模式等待时长元数据就绪后,默认放入一个完整时长的片段。 + useEffect(() => { + if (!open || mode !== "video" || segmentsSeededRef.current || !durationMs) return; + segmentsSeededRef.current = true; + setSegments([{ id: nanoid(), startMs: 0, endMs: durationMs, sourceNodeId: node.id }]); + }, [durationMs, mode, open]); + + const isVideoMode = mode === "video"; + const durationSec = durationMs > 0 ? durationMs / 1000 : 0; + const hasTimelineVideoClips = Boolean(timeline?.clips.some((clip) => clip.kind === "video")); + const videoProfile = useMemo(() => (model ? modelCapabilityConfigFor(config, model).video : undefined), [config, model]); + const defaultModel = config.videoModel || config.model || ""; + const defaultModelSupported = eligibleModels.includes(defaultModel); + const hasEligibleModels = eligibleModels.length > 0; + const firstEligibleModel = eligibleModels[0] || ""; + + // 切换模型后保持所选模式仍然有效,优先回退到“视频续写”。 + useEffect(() => { + if (!videoProfile?.operations.length || videoProfile.operations.includes(operation)) return; + setOperation(videoProfile.operations.includes("extend") ? "extend" : (videoProfile.operations[0] as CanvasVideoEditOperation)); + }, [operation, videoProfile]); + + const operationOptions = (videoProfile?.operations || []).map((value) => { + const operation = value as CanvasVideoEditOperation; + return { value: operation, label: videoOperationLabel(operation) }; + }); + + const rangeMs = useMemo(() => { + const start = Math.max(0, Math.round((startSec || 0) * 1000)); + const end = durationMs > 0 ? Math.min(durationMs, Math.round((endSec || 0) * 1000)) : Math.round((endSec || 0) * 1000); + return { startMs: start, endMs: Math.max(start, end) }; + }, [durationMs, endSec, startSec]); + + const addManualSegment = () => { + const last = segments[segments.length - 1]; + const maxEndMs = durationMs || last?.endMs || 0; + const startMs = last ? Math.min(maxEndMs, last.endMs) : 0; + const endMs = Math.max(startMs + MIN_SEGMENT_MS, maxEndMs); + setSegments((current) => [...current, { id: nanoid(), startMs, endMs, sourceNodeId: node.id }]); + }; + + const importTimelineSegments = () => { + try { + const result = buildTimelineImportSegments(node, nodes, connections, timeline, durationMs); + if (!result.ok) { + message.warning(result.error); + return; + } + setSegments(result.segments); + setPrompt((current) => current || `按时间线截取的 ${result.segments.length} 段视频,保持画面主体与镜头,重新生成每一段`); + message.success(`已从时间线导入 ${result.segments.length} 个片段`); + } catch (error) { + console.warn("时间线片段导入失败", error); + message.warning("读取时间线片段失败,请刷新后重试"); + } + }; + + const updateSegment = (id: string, patch: Partial>) => { + setSegments((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item))); + }; + + const removeSegment = (id: string) => { + setSegments((current) => current.filter((item) => item.id !== id)); + }; + + const handleConfirm = () => { + if (isVideoMode) { + if (!model) { + message.warning("请选择视频模型"); + return; + } + if (!segments.length) { + message.warning("请至少添加一个截取片段"); + return; + } + for (const segment of segments) { + if (segment.endMs - segment.startMs < MIN_SEGMENT_MS) { + message.warning("片段时长至少 0.1 秒"); + return; + } + } + onConfirm({ + mode: "video", + startMs: 0, + endMs: 0, + segments: segments.map(({ id, startMs, endMs, sourceNodeId, sourceStorageKey, sourceUrl }) => ({ id, startMs, endMs, sourceNodeId, sourceStorageKey, sourceUrl })), + model, + operation, + prompt: prompt.trim(), + }); + return; + } + if (!durationMs) { + message.warning("视频时长未就绪,请稍候再试"); + return; + } + if (rangeMs.endMs - rangeMs.startMs < MIN_SEGMENT_MS) { + message.warning("片段时长至少 0.1 秒"); + return; + } + onConfirm({ mode: "audio", startMs: rangeMs.startMs, endMs: rangeMs.endMs }); + }; + + const title = ( +
+ + {isVideoMode ? : } + +
+
{isVideoMode ? "截取片段并批量重生成" : "从视频提取声音"}
+
{node.title || "视频节点"}
+
+
+ ); + + return ( + +
+
+ {videoUrl ? ( +
+ + {isVideoMode ? ( +
+ {!defaultModelSupported ? ( +
+ + {hasEligibleModels + ? `当前默认模型${defaultModel ? `「${modelDisplayName(config, defaultModel)}」` : ""}不支持参考视频,已自动切换到「${modelDisplayName(config, firstEligibleModel)}」。` + : "当前配置中没有支持参考视频的视频模型,请先到设置里配置 Seedance / Agent Plan / NewAPI 渠道。"} + + {!hasEligibleModels ? ( + + ) : null} +
+ ) : null} + +
+
+ 时长 + {durationSec ? formatSegmentTime(durationSec) : "未知"} + 已选 + {segments.length} 段 +
+
+ + +
+
+ + {segments.length ? ( +
+ {segments.map((segment, index) => ( +
+
+ 片段 {index + 1} +
+ 起点 + updateSegment(segment.id, { startMs: Math.round((value ?? 0) * 1000) })} + className="w-full" + aria-label={`片段 ${index + 1} 起点(秒)`} + /> +
+
+ 终点 + updateSegment(segment.id, { endMs: Math.round((value ?? 0) * 1000) })} + className="w-full" + aria-label={`片段 ${index + 1} 终点(秒)`} + /> +
+
+
+ ))} +
+ ) : ( +
+ 还没有片段:手动添加起点/终点,或从时间线导入该节点的已有片段。 +
+ )} + +
+ +
; } @@ -739,6 +774,7 @@ function CreationComposer(props: ComposerProps) { onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)} /> + {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 67c18eccd..cfab9ffbc 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -696,6 +696,7 @@ /* Canvas 布局(画布页浮层定位,对应审计 P0-8.1/P1-2.1)*/ --canvas-sidebar-width: 232px; /* 项目侧栏宽度(短剧模式)*/ --canvas-panel-width: min(332px, calc(100vw - 24px)); /* 浮动面板默认宽度(活动任务等)*/ + --video-segment-preview-max-height: min(46vh, 480px); /* Prompt 面板(对应 #98 决策2 渐进式三区)*/ --prompt-panel-max-height: min(70vh, 640px); @@ -3419,17 +3420,19 @@ .library-page .app-page-header p { max-width: 560px; } /* 页面头部按钮优化 - 去除重边框 */ - .library-page .app-page-header .ant-btn, - .canvas-library-page .app-page-header .ant-btn { + html:not(.dark) .library-page .app-page-header .ant-btn, + html:not(.dark) .canvas-library-page .app-page-header .ant-btn { border: 1px solid rgba(17, 17, 17, 0.08) !important; background: #ffffff !important; + color: #171717 !important; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04) !important; transition: all 180ms ease-out !important; } - .library-page .app-page-header .ant-btn:hover, - .canvas-library-page .app-page-header .ant-btn:hover { + html:not(.dark) .library-page .app-page-header .ant-btn:hover, + html:not(.dark) .canvas-library-page .app-page-header .ant-btn:hover { border-color: rgba(17, 17, 17, 0.12) !important; background: #ffffff !important; + color: #171717 !important; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08) !important; transform: translateY(-1px); } diff --git a/web/test/asset-header-button-style.test.ts b/web/test/asset-header-button-style.test.ts new file mode 100644 index 000000000..0b9ad39e9 --- /dev/null +++ b/web/test/asset-header-button-style.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +describe("asset library header buttons", () => { + test("only forces white header buttons in light mode", () => { + const css = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8"); + const headerButtonRule = css.match(/html:not\(\.dark\) \.library-page \.app-page-header \.ant-btn,[\s\S]*?transition: all 180ms ease-out !important;\s*}/)?.[0] || ""; + const headerButtonHoverRule = css.match(/html:not\(\.dark\) \.library-page \.app-page-header \.ant-btn:hover,[\s\S]*?transform: translateY\(-1px\);\s*}/)?.[0] || ""; + + expect(headerButtonRule).toContain("background: #ffffff !important;"); + expect(headerButtonRule).toContain("color: #171717 !important;"); + expect(headerButtonHoverRule).toContain("background: #ffffff !important;"); + expect(headerButtonHoverRule).toContain("color: #171717 !important;"); + expect(css).not.toContain("\n .library-page .app-page-header .ant-btn,\n"); + }); +}); diff --git a/web/test/assets-page-batch-toolbar.test.ts b/web/test/assets-page-batch-toolbar.test.ts new file mode 100644 index 000000000..479b67499 --- /dev/null +++ b/web/test/assets-page-batch-toolbar.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +describe("assets page batch toolbar", () => { + test("places select all before cancel selection", () => { + const source = readFileSync(resolve(import.meta.dir, "../src/pages/assets/index.tsx"), "utf8"); + const selectAllIndex = source.indexOf(">全选"); + const clearSelectionIndex = source.indexOf(">取消选择"); + + expect(selectAllIndex).toBeGreaterThanOrEqual(0); + expect(clearSelectionIndex).toBeGreaterThanOrEqual(0); + expect(selectAllIndex).toBeLessThan(clearSelectionIndex); + }); +}); diff --git a/web/test/canvas-resource-references.test.ts b/web/test/canvas-resource-references.test.ts new file mode 100644 index 000000000..7376fec54 --- /dev/null +++ b/web/test/canvas-resource-references.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; + +import { collectUpstreamVideoNodes } from "../src/lib/canvas/canvas-resource-references"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; + +function videoNode(id: string): CanvasNodeData { + return { + id, + type: CanvasNodeType.Video, + title: id, + position: { x: 0, y: 0 }, + width: 100, + height: 100, + metadata: { content: `data:video/mp4;base64,${id}` }, + }; +} + +function textNode(id: string): CanvasNodeData { + return { + id, + type: CanvasNodeType.Text, + title: id, + position: { x: 0, y: 0 }, + width: 100, + height: 60, + metadata: { content: id }, + }; +} + +function connection(fromNodeId: string, toNodeId: string): CanvasConnection { + return { id: `conn-${fromNodeId}-${toNodeId}`, fromNodeId, toNodeId }; +} + +describe("collectUpstreamVideoNodes", () => { + test("下游视频节点能回溯到上游视频源", () => { + const source = videoNode("source-video"); + const segment = videoNode("segment-video"); + const target = videoNode("target-video"); + const text = textNode("script"); + const nodes = [target, segment, source, text]; + const connections = [connection("source-video", "segment-video"), connection("segment-video", "target-video"), connection("script", "segment-video")]; + expect(collectUpstreamVideoNodes("target-video", nodes, connections).map((node) => node.id)).toEqual(["target-video", "segment-video", "source-video"]); + }); + + test("存在环时不会死循环", () => { + const a = videoNode("a"); + const b = videoNode("b"); + const nodes = [a, b]; + const connections = [connection("a", "b"), connection("b", "a")]; + expect(collectUpstreamVideoNodes("a", nodes, connections).length).toBe(2); + }); +}); diff --git a/web/test/canvas-video-regeneration.test.ts b/web/test/canvas-video-regeneration.test.ts new file mode 100644 index 000000000..fbbae345f --- /dev/null +++ b/web/test/canvas-video-regeneration.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test"; + +import { listVideoReferenceModels, validateVideoSegmentBatch, videoReferenceOperationError, videoReferenceRegenerationError, videoReferenceSegmentError } from "../src/lib/canvas/canvas-video-regeneration"; +import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; +import { defaultConfig, type AiConfig, type ModelChannel } from "../src/stores/use-config-store"; + +function supportedVideoConfig(overrides: { model?: string; maxVideos?: number; maxVideoDurationSeconds?: number; operations?: string[]; baseUrl?: string } = {}): AiConfig { + const model = overrides.model || "seedance-1.0-pro"; + const capability = defaultModelCapabilityConfig("newapi-channel-2", model); + if (capability.video) { + capability.video.references.maxVideos = overrides.maxVideos ?? 3; + capability.video.references.maxVideoDurationSeconds = overrides.maxVideoDurationSeconds ?? 15; + capability.video.operations = overrides.operations ?? ["text_to_video", "image_to_video", "extend"]; + } + const channel: ModelChannel = { + id: "test", + name: "测试渠道", + baseUrl: overrides.baseUrl || "https://api.example.com", + apiKey: "test-key", + apiFormat: "openai", + interfaceType: "newapi-channel-2", + models: [model], + scope: "user", + modelCosts: [ + { + model, + capability: "video", + protocol: "newapi-channel-2", + billingMode: "per_second", + unitPriceMicrocredits: 1, + capabilityConfig: capability, + }, + ], + }; + return { + ...defaultConfig, + model: `test::${model}`, + videoModel: `test::${model}`, + models: [`test::${model}`], + videoModels: [`test::${model}`], + channels: [channel], + baseUrl: channel.baseUrl, + }; +} + +describe("videoReferenceRegenerationError", () => { + test("模型不支持参考视频时返回提示", () => { + const config: AiConfig = { ...defaultConfig, model: "default::grok-imagine-video", videoModel: "default::grok-imagine-video" }; + expect(videoReferenceRegenerationError(config)).toContain("不支持参考视频"); + }); + + test("模型没有可用生成模式时返回提示", () => { + const config = supportedVideoConfig({ operations: [] }); + expect(videoReferenceRegenerationError(config)).toContain("没有可用的视频生成模式"); + }); + + test("模型支持参考视频且有可用模式时无错误", () => { + expect(videoReferenceRegenerationError(supportedVideoConfig())).toBe(""); + }); +}); + +describe("videoReferenceOperationError", () => { + test("所选模式不在模型能力中时返回提示", () => { + const config = supportedVideoConfig({ operations: ["text_to_video", "image_to_video", "extend"] }); + expect(videoReferenceOperationError(config, "inpaint")).toContain("不支持所选生成模式"); + }); + + test("所选模式在模型能力中时无错误", () => { + expect(videoReferenceOperationError(supportedVideoConfig(), "extend")).toBe(""); + }); +}); + +describe("listVideoReferenceModels", () => { + test("默认配置没有可用的参考视频模型", () => { + const config: AiConfig = { ...defaultConfig, model: "default::grok-imagine-video", videoModel: "default::grok-imagine-video" }; + expect(listVideoReferenceModels(config)).toEqual([]); + }); + + test("只返回支持参考视频且有可用模式的模型", () => { + expect(listVideoReferenceModels(supportedVideoConfig())).toEqual(["test::seedance-1.0-pro"]); + }); + + test("过滤掉没有可用生成模式的模型", () => { + const config = supportedVideoConfig({ operations: [] }); + expect(listVideoReferenceModels(config)).toEqual([]); + }); + + test("过滤掉未配置 API Key 的渠道模型", () => { + const config = supportedVideoConfig(); + config.channels[0].apiKey = ""; + expect(listVideoReferenceModels(config)).toEqual([]); + }); +}); + +describe("videoReferenceSegmentError", () => { + test("片段超过模型参考视频上限时返回提示", () => { + const config = supportedVideoConfig({ maxVideoDurationSeconds: 15 }); + expect(videoReferenceSegmentError(config, 16000)).toContain("不能超过当前模型参考视频上限(15 秒)"); + }); + + test("Seedance 单段少于 2 秒时返回提示", () => { + const config = supportedVideoConfig({ model: "seedance-1.0-pro" }); + expect(videoReferenceSegmentError(config, 1500)).toContain("至少 2 秒"); + }); + + test("合法片段时长无错误", () => { + expect(videoReferenceSegmentError(supportedVideoConfig(), 5000)).toBe(""); + }); +}); + +describe("validateVideoSegmentBatch", () => { + test("模型、模式与片段时长都合法时无错误", () => { + expect(validateVideoSegmentBatch(supportedVideoConfig(), [{ startMs: 2000, endMs: 5000 }], "extend")).toBe(""); + }); + + test("模型不支持参考视频时返回首个错误", () => { + const config: AiConfig = { ...defaultConfig, model: "default::grok-imagine-video", videoModel: "default::grok-imagine-video" }; + expect(validateVideoSegmentBatch(config, [{ startMs: 2000, endMs: 5000 }], "extend")).toContain("不支持参考视频"); + }); + + test("模式不在模型能力中时返回操作错误", () => { + expect(validateVideoSegmentBatch(supportedVideoConfig(), [{ startMs: 2000, endMs: 5000 }], "inpaint")).toContain("不支持所选生成模式"); + }); + + test("片段超过模型时长上限时返回片段错误", () => { + expect(validateVideoSegmentBatch(supportedVideoConfig({ maxVideoDurationSeconds: 15 }), [{ startMs: 0, endMs: 16000 }], "extend")).toContain("不能超过当前模型参考视频上限"); + }); +}); diff --git a/web/test/canvas-video-timeline-segments.test.ts b/web/test/canvas-video-timeline-segments.test.ts new file mode 100644 index 000000000..bb7c11a4e --- /dev/null +++ b/web/test/canvas-video-timeline-segments.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; + +import { buildTimelineImportSegments } from "../src/lib/canvas/canvas-video-timeline-segments"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; +import type { TimelineProject } from "../src/types/timeline"; + +function videoNode(id: string, title: string): CanvasNodeData { + return { + id, + type: CanvasNodeType.Video, + title, + position: { x: 0, y: 0 }, + width: 100, + height: 100, + metadata: { content: `data:video/mp4;base64,${id}`, durationMs: 10_000 }, + }; +} + +function connection(fromNodeId: string, toNodeId: string): CanvasConnection { + return { id: `conn-${fromNodeId}-${toNodeId}`, fromNodeId, toNodeId }; +} + +const timeline: TimelineProject = { + version: 2, + tracks: [], + clips: [ + { + id: "clip-1", + kind: "video", + nodeId: "source-video", + trackId: "video", + startMs: 0, + durationMs: 3_000, + title: "源视频", + sourceStartMs: 2_000, + sourceDurationMs: 3_000, + }, + ], + durationMs: 3_000, +}; + +describe("buildTimelineImportSegments", () => { + test("下游节点能从上游视频节点导入时间线片段", () => { + const source = videoNode("source-video", "源视频"); + const segment = videoNode("segment-video", "片段"); + const target = videoNode("target-video", "结果"); + const nodes = [target, segment, source]; + const connections = [connection("source-video", "segment-video"), connection("segment-video", "target-video")]; + const result = buildTimelineImportSegments(target, nodes, connections, timeline, 10_000); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.segments).toHaveLength(1); + expect(result.segments[0]).toMatchObject({ startMs: 2_000, endMs: 5_000, sourceNodeId: "source-video" }); + }); + + test("旧数据 nodeId 不一致时按唯一标题回退匹配", () => { + const source = videoNode("source-video", "源视频"); + const target = videoNode("target-video", "结果"); + const nodes = [target, source]; + const connections = [connection("source-video", "target-video")]; + const oldTimeline: TimelineProject = { + ...timeline, + clips: [{ ...timeline.clips[0], nodeId: "legacy-source-id" }], + }; + const result = buildTimelineImportSegments(target, nodes, connections, oldTimeline, 10_000); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.segments[0].sourceNodeId).toBe("source-video"); + }); + + test("没有匹配片段时返回错误", () => { + const target = videoNode("target-video", "结果"); + const result = buildTimelineImportSegments(target, [target], [], timeline, 10_000); + expect(result.ok).toBe(false); + }); +}); diff --git a/web/test/create-library-button.test.ts b/web/test/create-library-button.test.ts new file mode 100644 index 000000000..90164b37e --- /dev/null +++ b/web/test/create-library-button.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +describe("creation library button", () => { + test("places a library control beside the generation mode picker", () => { + const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8"); + const modePickerIndex = source.indexOf(""); + const libraryButtonIndex = source.indexOf('className="creation-chat-control" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"'); + + expect(modePickerIndex).toBeGreaterThanOrEqual(0); + expect(libraryButtonIndex).toBeGreaterThan(modePickerIndex); + expect(source.slice(libraryButtonIndex, libraryButtonIndex + 180)).toContain(""); + expect(source.slice(libraryButtonIndex, libraryButtonIndex + 180)).toContain("素材库"); + }); + + test("uploads from the library without adding a reference before confirmation", () => { + const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8"); + const uploadStart = source.indexOf("const uploadLibraryAssets = async"); + const uploadEnd = source.indexOf("const handleFileChange", uploadStart); + + expect(uploadStart).toBeGreaterThanOrEqual(0); + expect(uploadEnd).toBeGreaterThan(uploadStart); + expect(source.slice(uploadStart, uploadEnd)).not.toContain("setAttachments"); + expect(source).toContain("onUpload={uploadLibraryAssets}"); + expect(source).not.toContain("onUpload={() => fileInputRef.current?.click()}"); + }); +});