Fix video canvas media imports and node layout
This commit is contained in:
parent
4f9453b8b2
commit
fe1c9b60f3
@ -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<HTMLDivElement | null>(null);
|
||||
const uploadQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const fileDragDepth = useRef(0);
|
||||
const [fileDragActive, setFileDragActive] = useState(false);
|
||||
const replaceFileInputRef=useRef<HTMLInputElement|null>(null);
|
||||
const replaceFileNodeId=useRef<string|null>(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<HTMLDivElement>) =>
|
||||
Array.from(event.dataTransfer.types).includes("Files");
|
||||
const isFileDropBlocked = (event: ReactDragEvent<HTMLDivElement>) =>
|
||||
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));
|
||||
}}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0" style={{backgroundImage:"radial-gradient(circle,rgba(255,255,255,0.14) 0.7px,transparent 0.7px)",backgroundPosition:"0.5px 0.5px",backgroundSize:"16px 16px"}} />
|
||||
{fileDragActive && (
|
||||
<div className="pointer-events-none absolute inset-3 z-[1800] flex items-center justify-center rounded-2xl border-2 border-dashed border-cyan-300 bg-cyan-300/5" role="status" aria-live="polite">
|
||||
<div className="rounded-xl bg-[#252525] px-6 py-4 text-sm text-white shadow-xl">
|
||||
松开即可导入图片、视频或音频 · 每次最多 10 个
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lineConnectState ? (
|
||||
<div
|
||||
@ -10125,17 +10202,23 @@ export function LiblibVideoCanvasReplica({
|
||||
{(savedGroupTemplates?.templates||[]).filter(t=>t.projectId!==canvasId).map((template,index)=><button key={`remote-${index}`} className="mt-2 block w-full truncate text-left" title={template.projectTitle} onClick={()=>importGroup(template)}>{template.name}</button>)}
|
||||
<label className="mt-3 block cursor-pointer text-white/60">导入编组模板<input type="file" accept=".json" className="hidden" onChange={async e=>{const file=e.target.files?.[0];e.target.value="";if(!file)return;if(file.size>1048576){toast.error("模板不能超过 1 MiB");return;}try{importGroup(JSON.parse(await file.text()));}catch{toast.error("模板格式无效");}}}/></label>
|
||||
</details>
|
||||
<input ref={replaceFileInputRef} type="file" className="hidden" onChange={async event=>{
|
||||
const file=event.target.files?.[0],nodeId=replaceFileNodeId.current;event.target.value="";
|
||||
if(!file||!nodeId)return;
|
||||
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:"上传失败");}
|
||||
<input ref={replaceFileInputRef} data-testid="canvas-replace-file-input" type="file" className="hidden" onChange={async event=>{
|
||||
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 ? (
|
||||
<CancelGenerationConfirmDialog
|
||||
@ -18623,7 +18706,7 @@ function CloneCanvasNode({
|
||||
</span>
|
||||
<div className="relative font-semibold text-[14px]">{node.title}</div>
|
||||
<div className="relative mt-2 line-clamp-3 px-5 text-[11px] leading-5 text-[#6f7482]">{node.subtitle}</div>
|
||||
<button className="relative mt-3 h-8 rounded-[8px] border border-[#dfe3ff] bg-white px-4 text-[11px] text-[#5161ff]" onClick={onChooseFile} type="button">
|
||||
<button className="relative mt-3 h-8 rounded-[8px] border border-[#dfe3ff] bg-white px-4 text-[11px] text-[#5161ff]" onClick={onChooseFile} onPointerDown={(event)=>event.stopPropagation()} type="button">
|
||||
选择文件
|
||||
</button>
|
||||
</div>
|
||||
@ -18840,59 +18923,83 @@ function TargetModuleCanvasNode({
|
||||
selected && "outline outline-1 outline-white/22"
|
||||
)}
|
||||
>
|
||||
{kind === "director" ? null : (
|
||||
<div className={cn("absolute inset-x-0 flex justify-center text-white/18", heroTop)}>
|
||||
<TargetModuleHeroGraphic kind={kind} />
|
||||
</div>
|
||||
)}
|
||||
{kind === "video-clip" ? (
|
||||
<div className="absolute inset-x-0 top-[254px] text-center text-[16px] text-white/34">
|
||||
空空如也,请连接视频节点后操作
|
||||
</div>
|
||||
) : kind === "director" ? (
|
||||
<div className="absolute inset-x-0 top-[46px] flex flex-col items-center px-10 text-center">
|
||||
<div className="max-w-[178px] text-[14px] leading-[22px] text-white/42">
|
||||
在3D空间中搭建场景并进行多视角截图
|
||||
{kind === "image" ? (
|
||||
<div className="flex h-full flex-col gap-3 p-5" data-testid="image-node-empty-state">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_96px] gap-4">
|
||||
<div>
|
||||
<div className="mb-2 text-[14px] leading-5 text-white/42">尝试:</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{actions.image.map((action) => (
|
||||
<button
|
||||
className="flex min-h-6 items-center gap-3 text-left text-[14px] leading-5 text-white/82 hover:text-white"
|
||||
key={action.label}
|
||||
onClick={() => onAction(action.label)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<ModuleActionIcon icon={action.icon} />
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true" className="pointer-events-none flex justify-center pt-8 text-white/18">
|
||||
<TargetModuleHeroGraphic kind="image" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto min-w-0 text-[12px] leading-[18px] text-white/42" data-testid="image-node-description">
|
||||
<p className="mb-2">
|
||||
可直接文字生图,或上传图片输入文字指令对图片进行编辑,如:将背景改为雪夜
|
||||
</p>
|
||||
<div className="truncate text-white/58" title={parameterSummary}>{parameterSummary}</div>
|
||||
</div>
|
||||
<button
|
||||
className="mt-7 flex h-6 items-center rounded-[6px] bg-white/10 px-2 text-[12px] font-medium leading-none text-white/86 transition hover:bg-white/15"
|
||||
onClick={() => onAction("打开导演台")}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
打开导演台
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("absolute left-8", actionTop)}>
|
||||
<div className="mb-5 text-[17px] text-white/42">尝试:</div>
|
||||
<div className={cn(kind === "image" ? "space-y-[10px]" : "space-y-5")}>
|
||||
{actions[kind].map((action) => (
|
||||
<>
|
||||
{kind === "director" ? null : (
|
||||
<div className={cn("absolute inset-x-0 flex justify-center text-white/18", heroTop)}>
|
||||
<TargetModuleHeroGraphic kind={kind} />
|
||||
</div>
|
||||
)}
|
||||
{kind === "video-clip" ? (
|
||||
<div className="absolute inset-x-0 top-[254px] text-center text-[16px] text-white/34">
|
||||
空空如也,请连接视频节点后操作
|
||||
</div>
|
||||
) : kind === "director" ? (
|
||||
<div className="absolute inset-x-0 top-[46px] flex flex-col items-center px-10 text-center">
|
||||
<div className="max-w-[178px] text-[14px] leading-[22px] text-white/42">
|
||||
在3D空间中搭建场景并进行多视角截图
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-3 leading-none text-white/82 hover:text-white",
|
||||
kind === "image" ? "text-[16px]" : "text-[18px]"
|
||||
)}
|
||||
key={action.label}
|
||||
onClick={() => onAction(action.label)}
|
||||
className="mt-7 flex h-6 items-center rounded-[6px] bg-white/10 px-2 text-[12px] font-medium leading-none text-white/86 transition hover:bg-white/15"
|
||||
onClick={() => onAction("打开导演台")}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<ModuleActionIcon icon={action.icon} />
|
||||
<span>{action.label}</span>
|
||||
打开导演台
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("absolute left-8", actionTop)}>
|
||||
<div className="mb-5 text-[17px] text-white/42">尝试:</div>
|
||||
<div className="space-y-5">
|
||||
{actions[kind].map((action) => (
|
||||
<button
|
||||
className="flex items-center gap-3 text-[18px] leading-none text-white/82 hover:text-white"
|
||||
key={action.label}
|
||||
onClick={() => onAction(action.label)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<ModuleActionIcon icon={action.icon} />
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{kind === "image" ? (
|
||||
<div className="absolute bottom-5 left-8 right-8 text-[12px] leading-[18px] text-white/42">
|
||||
<div className="mb-3 line-clamp-2">
|
||||
可直接文字生图,或上传图片输入文字指令对图片进行编辑,如:将背景改为雪夜
|
||||
</div>
|
||||
<div className="truncate text-white/58">{parameterSummary}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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。用户要求先本地测试,本轮不再更新或切换服务器版本。
|
||||
|
||||
27
docs/video-canvas-media-drop-2026-09-18.md
Normal file
27
docs/video-canvas-media-drop-2026-09-18.md
Normal file
@ -0,0 +1,27 @@
|
||||
# 视频画布拖入素材与图片节点排版修复
|
||||
|
||||
本次仅修改及测试本地代码,不更新服务器。
|
||||
|
||||
## 修改
|
||||
|
||||
- 画布接收系统文件拖放,显示拖入提示,按当前平移/缩放换算鼠标落点;多文件创建独立素材节点。
|
||||
- 接入现有真实图片与音视频上传接口,显示上传中/成功/失败状态,并通过现有画布保存流程持久化。
|
||||
- 连续多批导入共用串行上传队列,避免撞上音视频接口的同用户并发限制。
|
||||
- “重新选择文件”复用同一套格式/大小校验、180秒超时和上传队列;替换失败时保留原素材,且不允许用错误类型破坏既有连线。
|
||||
- 上传中或上传失败的节点可以保留连线,但执行下游功能时会明确提示等待上传或重新选择素材。
|
||||
- 修复旧上传输入节点刷新后被误判成生成节点的问题,保存/重开后仍保留“选择文件”入口。
|
||||
- 修复“选择文件”按钮按下时被节点拖动事件截获、无法打开文件选择器的问题。
|
||||
- 每批最多10个素材,超量及不支持/空/超限文件给出提示,合法文件继续导入;兼容没有 MIME 的常见本地文件。
|
||||
- 图片生成空节点的按钮、图标、说明、参数改为正常流排版,保留五项操作,修复重叠和裁切。
|
||||
|
||||
## 本地验证
|
||||
|
||||
- 使用隔离本地数据库、S3测试存储与同源网关,无生产用户数据和付费生图调用。
|
||||
- 浏览器 DataTransfer 文件拖放:一张 PNG 与一个无 MIME 的 MP4 混合导入,再立即拖入第二批 MP4 与无效文本;3个合法素材通过真实上传接口,文本被拒绝,不产生伪素材节点。
|
||||
- 80%画布缩放下导入节点中心与鼠标落点误差小于2像素;刷新后图片可解码,视频元数据可加载。
|
||||
- 损坏 MP4 由真实上传接口拒绝;画布显示失败原因、不填写素材地址,刷新后失败提示仍保留。
|
||||
- 对图片空节点测量全部五个按钮和说明区域的边界:互不重叠、未超出卡片,五个按钮均通过浏览器点击可达性检查。
|
||||
- TypeScript和67项文件导入、连线、保存队列、画布后续功能及任务回归测试通过。
|
||||
- 浏览器实测图片素材连到图片生成节点后可保存并刷新恢复;视频误连图片节点被拒绝,不生成装饰性连线。
|
||||
- 浏览器实测上传图片输入节点:错误视频类型被拒绝且节点不变,正确图片真实上传后转换为素材节点,刷新可正常解码。
|
||||
- 报告、脚本、截图和测试素材保留在忽略目录 `.qa/media-drop/`。
|
||||
@ -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") {
|
||||
|
||||
42
lib/video-canvas/media-import.ts
Normal file
42
lib/video-canvas/media-import.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { IMAGE_UPLOAD_TOO_LARGE_MESSAGE, isImageUploadTooLarge } from "@/lib/image-upload-limits";
|
||||
|
||||
const mimeByExtension: Record<string, string> = {
|
||||
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}:不支持此素材格式`);
|
||||
}
|
||||
@ -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/,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -30,6 +30,7 @@ test("audio uploads once, resumes same identity, and isolates tenant/task/conten
|
||||
const ids: string[] = [],
|
||||
jobs = new Set<string>();
|
||||
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 });
|
||||
}
|
||||
|
||||
@ -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("已删除");
|
||||
});
|
||||
|
||||
|
||||
27
tests/routes/video-canvas-media-import.test.ts
Normal file
27
tests/routes/video-canvas-media-import.test.ts
Normal file
@ -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 个素材,其余文件请分批拖入"]);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user