diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 5276a5ab5..59a932c4d 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -125,6 +125,7 @@ import { VideoArchiveButton } from "@/components/canvas/VideoArchiveButton"; import { VideoPromptToolDialog } from "@/components/canvas/VideoPromptToolDialog"; import type { PromptToolMode } from "@/lib/video-canvas/prompt-tools"; import { useVideoCanvasPersistence } from "@/hooks/use-video-canvas-persistence"; +import { canvasMediaType, prepareCanvasMediaFiles } from "@/lib/video-canvas/media-import"; import type { CanvasInitialProject } from "@/types/canvas"; import { getVideoCanvasFunctionBillingCopy, @@ -4161,7 +4162,9 @@ function extractTrailingNumber(value: string | undefined) { } function inferLegacyTargetModuleKind(node: CloneNode): AddModuleKind | null { - if (node.kind === "media" || getPresetMode(node)) return null; + // Explicit upload/input placeholders are resources, not legacy generator nodes. + // Reclassifying them removes the file picker after the first save + reload. + if (node.kind === "media" || node.kind === "input" || getPresetMode(node)) return null; const action = getDraftString(node, "action"); if (action.endsWith("_RESOURCE") || action === "TEXT_RESOURCE") return null; @@ -4581,6 +4584,9 @@ export function LiblibVideoCanvasReplica({ }: LiblibVideoCanvasReplicaProps) { const router = useRouter(); const canvasRef = useRef(null); + const uploadQueueRef = useRef>(Promise.resolve()); + const fileDragDepth = useRef(0); + const [fileDragActive, setFileDragActive] = useState(false); const replaceFileInputRef=useRef(null); const replaceFileNodeId=useRef(null); const connectionDragCleanupRef = useRef<(() => void) | null>(null); @@ -5455,10 +5461,38 @@ export function LiblibVideoCanvasReplica({ [addNodes, nodes, toCanvasPoint] ); + const uploadCanvasMediaFile = useCallback(async (file: File) => { + const mediaType = canvasMediaType(file); + if (mediaType === "image") { + const result = await imageGenerationService.uploadImage(file); + if (!result.success || !result.data?.url) throw new Error(result.error || "图片上传失败"); + return { mediaType, url: result.data.url }; + } + const form = new FormData(); + form.append("file", file); + const response = await fetch("/api/canvas/media/upload", { + method: "POST", + body: form, + signal: AbortSignal.timeout(180_000), + }); + const result = await response.json(); + if (!response.ok || !result.success || !result.data?.url) + throw new Error(result.error || "素材上传失败"); + return { + mediaType, + url: result.data.url as string, + duration: Number(result.data.duration) || undefined, + width: Number(result.data.width) || undefined, + height: Number(result.data.height) || undefined, + }; + }, []); + const addUploadedFilesAtPoint = useCallback( (files: File[], center: { x: number; y: number }, connectTargetId?: string) => { if (files.length === 0) return; - const batch = files.slice(0, 10); + const { files: batch, errors } = prepareCanvasMediaFiles(files); + if (errors.length) toast.error(errors.slice(0, 3).join("\n")); + if (!batch.length) return; const newNodes = batch.map((file, index) => { const created = createUploadedMediaNode(file, "", 0, 0, nodes.length + index + 1); return { ...created, x: Math.round(center.x - created.width / 2 + index * 450), @@ -5469,25 +5503,14 @@ export function LiblibVideoCanvasReplica({ setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); - void (async () => { + uploadQueueRef.current = uploadQueueRef.current.catch(() => {}).then(async () => { for (const [index, file] of batch.entries()) { const id = newNodes[index].id; try { - let data: { url: string; duration?: number; width?: number; height?: number }; - if (file.type.startsWith("image/")) { - const result = await imageGenerationService.uploadImage(file); - if (!result.success || !result.data?.url) throw new Error(result.error || "图片上传失败"); - data = result.data; - } else { - const form = new FormData(); - form.append("file", file); - const response = await fetch("/api/canvas/media/upload", { method: "POST", body: form, signal: AbortSignal.timeout(180_000) }); - const result = await response.json(); - if (!response.ok || !result.success || !result.data?.url) throw new Error(result.error || "上传失败"); - data = result.data; - } + const data = await uploadCanvasMediaFile(file); setNodes(current => current.map(node => node.id === id ? { ...node, draftValues: { ...node.draftValues, mediaUrl: data.url, url: [data.url], uploadStatus: "complete", + uploadError: undefined, durationSeconds: data.duration, outputSizeLabel: data.width && data.height ? `${data.width} × ${data.height}` : undefined, } } : node)); toast.success(`${newNodes[index].title} 上传完成`); @@ -5499,9 +5522,9 @@ export function LiblibVideoCanvasReplica({ toast.error(`${newNodes[index].title}:${message}`); } } - })(); + }); }, - [addNodes, nodes.length] + [addNodes, nodes.length, uploadCanvasMediaFile] ); const addUploadedFiles = useCallback( @@ -5511,6 +5534,26 @@ export function LiblibVideoCanvasReplica({ [addUploadedFilesAtPoint, toCanvasPoint] ); + const resetFileDrag = useCallback(() => { + fileDragDepth.current = 0; + setFileDragActive(false); + }, []); + useEffect(() => { + window.addEventListener("blur", resetFileDrag); + window.addEventListener("dragend", resetFileDrag); + window.addEventListener("drop", resetFileDrag); + return () => { + window.removeEventListener("blur", resetFileDrag); + window.removeEventListener("dragend", resetFileDrag); + window.removeEventListener("drop", resetFileDrag); + }; + }, [resetFileDrag]); + + const isCanvasFileDrag = (event: ReactDragEvent) => + Array.from(event.dataTransfer.types).includes("Files"); + const isFileDropBlocked = (event: ReactDragEvent) => + Boolean((event.target as HTMLElement).closest('[role="dialog"], input, textarea, [contenteditable="true"]')); + const addAssetFromLibrary = useCallback( (asset: CanvasLibraryAsset) => { const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); @@ -10075,8 +10118,42 @@ export function LiblibVideoCanvasReplica({ onPointerUp={onPointerUp} onWheel={onCanvasWheel} ref={canvasRef} + data-testid="video-canvas-dropzone" + onDragEnter={(event) => { + if (!isCanvasFileDrag(event)) return; + event.preventDefault(); + fileDragDepth.current++; + if (!isFileDropBlocked(event) && !isBusy) setFileDragActive(true); + }} + onDragOver={(event) => { + if (!isCanvasFileDrag(event)) return; + event.preventDefault(); + const allowed = !isFileDropBlocked(event) && !isBusy; + event.dataTransfer.dropEffect = allowed ? "copy" : "none"; + setFileDragActive(allowed); + }} + onDragLeave={(event) => { + if (!isCanvasFileDrag(event)) return; + fileDragDepth.current = Math.max(0, fileDragDepth.current - 1); + if (!fileDragDepth.current) setFileDragActive(false); + }} + onDrop={(event) => { + if (!isCanvasFileDrag(event)) return; + event.preventDefault(); + event.stopPropagation(); + resetFileDrag(); + if (isFileDropBlocked(event) || isBusy) return; + addUploadedFilesAtPoint(Array.from(event.dataTransfer.files), toCanvasPoint(event.clientX, event.clientY)); + }} >
+ {fileDragActive && ( +
+
+ 松开即可导入图片、视频或音频 · 每次最多 10 个 +
+
+ )} {lineConnectState ? (
t.projectId!==canvasId).map((template,index)=>)} - { - 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:"上传失败");} + { + const selected=Array.from(event.target.files||[]),nodeId=replaceFileNodeId.current;event.target.value=""; + if(!selected.length||!nodeId)return; + const prepared=prepareCanvasMediaFiles(selected); + if(prepared.errors.length||!prepared.files[0]){toast.error(prepared.errors[0]||"请选择图片、视频或音频文件");return;} + const file=prepared.files[0],currentNode=nodes.find(node=>node.id===nodeId); + const expectedType=currentNode?.inputType; + if(expectedType&&["image","video","audio"].includes(expectedType)&&canvasMediaType(file)!==expectedType){ + toast.error(`请选择${expectedType==="image"?"图片":expectedType==="video"?"视频":"音频"}文件替换当前素材`);return; + } + uploadQueueRef.current=uploadQueueRef.current.catch(()=>{}).then(async()=>{ + try { + const data=await uploadCanvasMediaFile(file); + setNodes(current=>current.map(node=>node.id===nodeId?{...node,kind:"media",inputType:data.mediaType,title:file.name.replace(/\.[^.]+$/,"")||node.title,draftValues:{...node.draftValues,mediaType:data.mediaType,mediaUrl:data.url,url:[data.url],isExample:false,generated:false,generationStatus:"idle",durationSeconds:data.duration,outputSizeLabel:data.width&&data.height?`${data.width} × ${data.height}`:undefined,uploadStatus:"complete",uploadError:undefined}}:node)); + toast.success(`${file.name} 替换完成`); + }catch(error){toast.error(error instanceof Error?error.message:"上传失败,原素材已保留");} + }); }}/> {cancelGenerationConfirmNodeId ? (
{node.title}
{node.subtitle}
-
@@ -18840,59 +18923,83 @@ function TargetModuleCanvasNode({ selected && "outline outline-1 outline-white/22" )} > - {kind === "director" ? null : ( -
- -
- )} - {kind === "video-clip" ? ( -
- 空空如也,请连接视频节点后操作 -
- ) : kind === "director" ? ( -
-
- 在3D空间中搭建场景并进行多视角截图 + {kind === "image" ? ( +
+
+
+
尝试:
+
+ {actions.image.map((action) => ( + + ))} +
+
+ +
+
+

+ 可直接文字生图,或上传图片输入文字指令对图片进行编辑,如:将背景改为雪夜 +

+
{parameterSummary}
-
) : ( -
-
尝试:
-
- {actions[kind].map((action) => ( + <> + {kind === "director" ? null : ( +
+ +
+ )} + {kind === "video-clip" ? ( +
+ 空空如也,请连接视频节点后操作 +
+ ) : kind === "director" ? ( +
+
+ 在3D空间中搭建场景并进行多视角截图 +
- ))} -
-
+
+ ) : ( +
+
尝试:
+
+ {actions[kind].map((action) => ( + + ))} +
+
+ )} + )} - {kind === "image" ? ( -
-
- 可直接文字生图,或上传图片输入文字指令对图片进行编辑,如:将背景改为雪夜 -
-
{parameterSummary}
-
- ) : null}
); diff --git a/docs/audio-processing-deployment-2026-09-18.md b/docs/audio-processing-deployment-2026-09-18.md index cee9387ca..b1e43fa46 100644 --- a/docs/audio-processing-deployment-2026-09-18.md +++ b/docs/audio-processing-deployment-2026-09-18.md @@ -1,6 +1,6 @@ # 自动转写与人声分离独立服务 — 2026-09-18 -本轮完成线上模型安装、算力服务器私有音频 API、主项目接入。服务部署说明见 `services/audio-processing/README.md`。 +本轮完成算力服务器模型安装、私有音频 API 和主项目接入代码。**按用户要求暂停主站发布,先进行本地验收。** 48 主站仍为 `229f4d72`,构建仍为 `.next-groups-upscale-20260918T133550`,未加入远程音频配置;候选版本构建已停止。算力服务器音频服务在暂停前已安装,不能将其等同于主站功能已上线。服务部署说明见 `services/audio-processing/README.md`。 ## 架构 @@ -21,4 +21,15 @@ - 在48执行实际主项目处理函数,经真实私有API调用算力服务器:转写保留手动字幕并新增3句;both/vocals/background三个分支分别生成2/1/1条独立音轨,保留原裁剪、时长、增益并关闭视频原声。测试隔离素材缓存与回传文件,不触碰用户画布;上传回调验证文件交付,不将此宣称为真实用户对象存储端到端验收。 - 算力服务器及48的认证 health 均返回 asr=true、separation=true;服务 bundle SHA-256 为 `22c20736634cbed32c3a3b227e71ce773f5da5763d52c021cd6a162130b54d22`。 -测试输入为合成语音/背景音,无付费生成调用。主站发布版本与构建结果在上线后补记。私有配置、模型权重、测试凭据、输入输出不提交 Git。 +## 暂停发布后的本地验收 + +- 主应用、音频 API 和模型均运行本机,使用隔离数据库 `codex_smart_edit_0918`、本地 S3 测试存储;本轮不调用线上音频服务。 +- 真实接口链通过:登录、上传、创建画布、剪辑计划、视频人声分离、自动转写、独立音轨再分离、仅保留人声/背景声、成片及 SRT 归档。同一 requestId 的重复提交复用任务。 +- 8.9秒中文合成样例:计划6.7秒、首次分离14.1秒、转写15.7秒、三次独立音轨分离各约10.8秒、成片58.5秒。包含本地开发环境冷编译,不作为线上性能结论。 +- 导出文件完整解码通过:H.264 854×480、AAC 48kHz 双声道;检查导出画面确认中文字幕实际烧录,SRT 包含识别文本。 +- 浏览器实际点击验证通过:播放预览、手动新增字幕、自动转写后立即刷新并重开编辑器、手动字幕保留、人声分离菜单全部三个选项、再次刷新后音轨数量与字幕保留。无页面脚本异常。 +- 本地测试素材最初使用跨端口 HTTP URL,被页面 CSP 正确拒绝;改为本地同源测试网关后预览播放通过,未修改产品 CSP。后台素材下载使用隔离测试进程的网络边界替身访问本地 S3;因此这些结果不代表公网存储网络/线上权限链已验收。 +- 音频/剪辑/任务61项回归通过,TypeScript通过。新增内容变化必须新建音频任务的断言。修正全链路测试成片节点坐标,避免测试节点互相遮挡。 +- 本地报告和截图位于忽略目录 `.qa/audio-local/`。当前是合成短样例验收,长素材、多人声/复杂音乐质量和生产环境端到端验收尚未覆盖。 + +测试输入为合成语音/背景音,无付费生成调用。私有配置、模型权重、测试凭据、输入输出不提交 Git。用户要求先本地测试,本轮不再更新或切换服务器版本。 diff --git a/docs/video-canvas-media-drop-2026-09-18.md b/docs/video-canvas-media-drop-2026-09-18.md new file mode 100644 index 000000000..4e294a1d5 --- /dev/null +++ b/docs/video-canvas-media-drop-2026-09-18.md @@ -0,0 +1,27 @@ +# 视频画布拖入素材与图片节点排版修复 + +本次仅修改及测试本地代码,不更新服务器。 + +## 修改 + +- 画布接收系统文件拖放,显示拖入提示,按当前平移/缩放换算鼠标落点;多文件创建独立素材节点。 +- 接入现有真实图片与音视频上传接口,显示上传中/成功/失败状态,并通过现有画布保存流程持久化。 +- 连续多批导入共用串行上传队列,避免撞上音视频接口的同用户并发限制。 +- “重新选择文件”复用同一套格式/大小校验、180秒超时和上传队列;替换失败时保留原素材,且不允许用错误类型破坏既有连线。 +- 上传中或上传失败的节点可以保留连线,但执行下游功能时会明确提示等待上传或重新选择素材。 +- 修复旧上传输入节点刷新后被误判成生成节点的问题,保存/重开后仍保留“选择文件”入口。 +- 修复“选择文件”按钮按下时被节点拖动事件截获、无法打开文件选择器的问题。 +- 每批最多10个素材,超量及不支持/空/超限文件给出提示,合法文件继续导入;兼容没有 MIME 的常见本地文件。 +- 图片生成空节点的按钮、图标、说明、参数改为正常流排版,保留五项操作,修复重叠和裁切。 + +## 本地验证 + +- 使用隔离本地数据库、S3测试存储与同源网关,无生产用户数据和付费生图调用。 +- 浏览器 DataTransfer 文件拖放:一张 PNG 与一个无 MIME 的 MP4 混合导入,再立即拖入第二批 MP4 与无效文本;3个合法素材通过真实上传接口,文本被拒绝,不产生伪素材节点。 +- 80%画布缩放下导入节点中心与鼠标落点误差小于2像素;刷新后图片可解码,视频元数据可加载。 +- 损坏 MP4 由真实上传接口拒绝;画布显示失败原因、不填写素材地址,刷新后失败提示仍保留。 +- 对图片空节点测量全部五个按钮和说明区域的边界:互不重叠、未超出卡片,五个按钮均通过浏览器点击可达性检查。 +- TypeScript和67项文件导入、连线、保存队列、画布后续功能及任务回归测试通过。 +- 浏览器实测图片素材连到图片生成节点后可保存并刷新恢复;视频误连图片节点被拒绝,不生成装饰性连线。 +- 浏览器实测上传图片输入节点:错误视频类型被拒绝且节点不变,正确图片真实上传后转换为素材节点,刷新可正常解码。 +- 报告、脚本、截图和测试素材保留在忽略目录 `.qa/media-drop/`。 diff --git a/lib/video-canvas/connections.ts b/lib/video-canvas/connections.ts index f6c8552cd..b06d9ca1a 100644 --- a/lib/video-canvas/connections.ts +++ b/lib/video-canvas/connections.ts @@ -53,6 +53,10 @@ export function connectedText(node: ConnectionNode) { export function assertConnectedInputsReady(nodes: ConnectionNode[]) { for (const node of nodes) { const draft = node.draftValues || {}; + if (draft.uploadStatus === "uploading") + throw Error(`上游「${node.title || node.id}」仍在上传,请等待上传完成`); + if (draft.uploadStatus === "failed") + throw Error(`上游「${node.title || node.id}」上传失败,请重新选择素材`); if (["pending", "running", "queued", "failed", "cancelled", "canceled", "interrupted"].includes(draft.generationStatus)) throw Error(`上游「${node.title || node.id}」尚未成功完成,请先完成该节点`); if (node.inputType === "text" || node.kind === "script" || node.kind === "text") { diff --git a/lib/video-canvas/media-import.ts b/lib/video-canvas/media-import.ts new file mode 100644 index 000000000..ecff3228b --- /dev/null +++ b/lib/video-canvas/media-import.ts @@ -0,0 +1,42 @@ +import { IMAGE_UPLOAD_TOO_LARGE_MESSAGE, isImageUploadTooLarge } from "@/lib/image-upload-limits"; + +const mimeByExtension: Record = { + jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", + avif: "image/avif", tif: "image/tiff", tiff: "image/tiff", svg: "image/svg+xml", + heic: "image/heic", heif: "image/heif", + mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm", + mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav", ogg: "audio/ogg", flac: "audio/flac", +}; +const acceptedTypes = new Set([...Object.values(mimeByExtension), "image/jpg", "image/x-tiff", "audio/mp3", "audio/x-m4a", "audio/x-wav", "audio/webm"]); + +// Finder and some browsers provide no MIME type. Only infer it for known extensions; +// the upload endpoints still validate/decode the actual file contents. +export function prepareCanvasMediaFiles(input: File[]) { + const files: File[] = [], errors: string[] = []; + for (const file of input) { + const originalType = file.type.toLowerCase(); + const type = !originalType || originalType === "application/octet-stream" + ? mimeByExtension[file.name.split(".").pop()?.toLowerCase() || ""] + : originalType; + if (!type || !acceptedTypes.has(type)) { + errors.push(`${file.name}:不支持此格式,请拖入图片、MP4/MOV/WebM 视频或音频文件`); + } else if (!file.size) { + errors.push(`${file.name}:文件为空,请选择完整素材`); + } else if (type.startsWith("image/") && isImageUploadTooLarge(file.size)) { + errors.push(`${file.name}:${IMAGE_UPLOAD_TOO_LARGE_MESSAGE}`); + } else if (!type.startsWith("image/") && file.size > 125 * 1024 * 1024) { + errors.push(`${file.name}:音视频不能超过 125 MiB`); + } else { + files.push(type === file.type ? file : new File([file], file.name, { type, lastModified: file.lastModified })); + } + } + if (files.length > 10) errors.push("每次最多导入 10 个素材,其余文件请分批拖入"); + return { files: files.slice(0, 10), errors }; +} + +export function canvasMediaType(file: File): "image" | "video" | "audio" { + if (file.type.startsWith("image/")) return "image"; + if (file.type.startsWith("video/")) return "video"; + if (file.type.startsWith("audio/")) return "audio"; + throw Error(`${file.name}:不支持此素材格式`); +} diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index f0928caf6..4f56595db 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", testMatch: [ + /routes\/video-canvas-media-import\.test\.ts/, /routes\/video-canvas-audio-remote\.test\.ts/, /routes\/video-canvas-groups-upscale\.test\.ts/, /routes\/video-canvas-connections\.test\.ts/, diff --git a/scripts/qa/smart-edit-main-chain.cjs b/scripts/qa/smart-edit-main-chain.cjs index ebf8222b2..9721ad0cc 100644 --- a/scripts/qa/smart-edit-main-chain.cjs +++ b/scripts/qa/smart-edit-main-chain.cjs @@ -195,7 +195,7 @@ const post = (p, b) => plan.captions[0].style = { ...plan.captions[0].style, fontFamily: "catalog-0006-font" }; let render = await task( { operation: "render", plan, sourceNodeId: "qa-edit" }, - { ...node, id: "qa-render", title: "本地验收成片", kind: "media", inputType: "video" }, + { ...node, id: "qa-render", x: 800, title: "本地验收成片", kind: "media", inputType: "video" }, ); let media = await fetch(render.url); assert.equal(media.status, 200); diff --git a/tests/routes/video-canvas-audio-remote.test.ts b/tests/routes/video-canvas-audio-remote.test.ts index 8a6e55be4..de7d7cca1 100644 --- a/tests/routes/video-canvas-audio-remote.test.ts +++ b/tests/routes/video-canvas-audio-remote.test.ts @@ -30,6 +30,7 @@ test("audio uploads once, resumes same identity, and isolates tenant/task/conten const ids: string[] = [], jobs = new Set(); let uploads = 0; + let expected = "fixture bytes"; global.fetch = (async (_url: any, init: any) => { if (init.method === "POST") { const input = JSON.parse(init.body); @@ -51,7 +52,7 @@ test("audio uploads once, resumes same identity, and isolates tenant/task/conten const id = String(_url).split("/").at(-2)!; let data = ""; for await (const c of init.body) data += c; - expect(data).toBe("fixture bytes"); + expect(data).toBe(expected); uploads++; jobs.add(id); return Response.json({ @@ -72,6 +73,11 @@ test("audio uploads once, resumes same identity, and isolates tenant/task/conten await remoteAudio("transcribe", file, "other", "task", dir); await remoteAudio("transcribe", file, "owner", "other-task", dir); expect(new Set(ids).size).toBe(3); + expected = "updated fixture"; + await writeFile(file, expected); + await remoteAudio("transcribe", file, "owner", "task", dir); + expect(new Set(ids).size).toBe(4); + expect(uploads).toBe(4); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/tests/routes/video-canvas-connections.test.ts b/tests/routes/video-canvas-connections.test.ts index bebe0f6cf..70bd88319 100644 --- a/tests/routes/video-canvas-connections.test.ts +++ b/tests/routes/video-canvas-connections.test.ts @@ -45,6 +45,8 @@ test("upstream failure or pending preview blocks downstream execution", () => { } expect(() => assertConnectedInputsReady([{ ...image("a"), draftValues: { sourceUrl: "https://media.example/preview.png", poster: "preview.png" } }])).toThrow("尚无可用素材"); expect(() => assertConnectedInputsReady([image("a")])).not.toThrow(); + expect(() => assertConnectedInputsReady([{ ...image("a"), title: "拖入图片", draftValues: { uploadStatus: "uploading" } }])).toThrow("仍在上传"); + expect(() => assertConnectedInputsReady([{ ...image("a"), title: "拖入图片", draftValues: { uploadStatus: "failed" } }])).toThrow("上传失败"); expect(() => connectedInputNodes([target], [edge("missing")], "target")).toThrow("已删除"); }); diff --git a/tests/routes/video-canvas-media-import.test.ts b/tests/routes/video-canvas-media-import.test.ts new file mode 100644 index 000000000..6cb586085 --- /dev/null +++ b/tests/routes/video-canvas-media-import.test.ts @@ -0,0 +1,27 @@ +import { test, expect } from "@playwright/test"; +import { canvasMediaType, prepareCanvasMediaFiles } from "@/lib/video-canvas/media-import"; + +test("Finder files without MIME retain bytes and get the correct image/video upload route", async () => { + const input = [new File(["image bytes"], "照片.PNG"), new File(["video bytes"], "镜头.MOV", { type: "application/octet-stream" })]; + const { files, errors } = prepareCanvasMediaFiles(input); + expect(errors).toEqual([]); + expect(files.map(f => f.type)).toEqual(["image/png", "video/quicktime"]); + expect(await Promise.all(files.map(f => f.text()))).toEqual(["image bytes", "video bytes"]); + expect(files.map(canvasMediaType)).toEqual(["image", "video"]); +}); + +test("invalid mixed drops cannot create empty or unsupported media nodes", () => { + const valid = new File(["video"], "clip.mp4", { type: "video/mp4" }); + const result = prepareCanvasMediaFiles([new File([], "folder"), new File([], "empty.png", { type: "image/png" }), new File(["text"], "wrong.png", { type: "text/plain" }), valid]); + expect(result.files).toEqual([valid]); + expect(result.errors).toHaveLength(3); + const oversized = new File(["video"], "large.mp4", { type: "video/mp4" }); + Object.defineProperty(oversized, "size", { value: 126 * 1024 * 1024 }); + expect(prepareCanvasMediaFiles([oversized]).errors[0]).toContain("125 MiB"); +}); + +test("a drop beyond the batch limit explicitly reports skipped files", () => { + const result = prepareCanvasMediaFiles(Array.from({ length: 12 }, (_, i) => new File(["png"], `${i}.png`, { type: "image/png" }))); + expect(result.files).toHaveLength(10); + expect(result.errors).toEqual(["每次最多导入 10 个素材,其余文件请分批拖入"]); +});