From 66112771763e54f93285202cc13495ac7bdbb2df Mon Sep 17 00:00:00 2001 From: Kane Huang Date: Sun, 9 Aug 2026 21:14:11 +1000 Subject: [PATCH 1/2] fix cancel not work for deletion and force import frame to project using frame number --- apps/server/src/api/materials.ts | 20 +++++++++++++---- apps/web/src/components/AppModals.tsx | 3 ++- apps/web/src/components/ImportModal.tsx | 2 +- apps/web/src/hooks/useImportWorkflow.ts | 26 +++++++++++++++++++--- apps/web/src/i18n/en.ts | 2 +- apps/web/src/i18n/zh.ts | 2 +- tests/import-order.test.ts | 9 ++++++++ tests/material-import-order.test.ts | 29 +++++++++++++++++++++++++ 8 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 tests/import-order.test.ts create mode 100644 tests/material-import-order.test.ts diff --git a/apps/server/src/api/materials.ts b/apps/server/src/api/materials.ts index 343bfcf..6f1d816 100644 --- a/apps/server/src/api/materials.ts +++ b/apps/server/src/api/materials.ts @@ -22,6 +22,17 @@ function isPng(bytes: Uint8Array): boolean { return bytes.length >= signature.length && signature.every((byte, i) => bytes[i] === byte); } +const materialNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +export function sortMaterialsByFrameNumber(materials: MaterialRow[]): MaterialRow[] { + return [...materials].sort( + (a, b) => + materialNameCollator.compare(a.name || "", b.name || "") || + a.created_at - b.created_at || + a.id.localeCompare(b.id) + ); +} + /** 把素材的 raw / processed 槽位分别复制为项目帧追加到末尾,返回新帧 id */ function importMaterialToProject(m: MaterialRow, projectId: string): string { const rawSrc = m.raw_path && existsSync(m.raw_path) ? m.raw_path : m.processed_path; @@ -358,7 +369,7 @@ export const materialsApi = new Elysia({ prefix: "/api" }) }, { body: t.Object({ ids: t.Array(t.String()) }) } ) - // 批量导入到项目(保持给定顺序,各 1 份) + // 批量导入到项目(按素材名称中的帧编号自然升序,各 1 份) .post( "/materials/batch-import", ({ body, status }) => { @@ -366,9 +377,10 @@ export const materialsApi = new Elysia({ prefix: "/api" }) if (!project) return status(404, "项目不存在"); let count = 0; try { - for (const id of body.ids) { - const m = getMaterial(id); - if (!m) continue; + const materials = sortMaterialsByFrameNumber( + body.ids.map((id) => getMaterial(id)).filter((m): m is MaterialRow => m !== null) + ); + for (const m of materials) { importMaterialToProject(m, body.projectId); count++; } diff --git a/apps/web/src/components/AppModals.tsx b/apps/web/src/components/AppModals.tsx index 804ae97..f84f239 100644 --- a/apps/web/src/components/AppModals.tsx +++ b/apps/web/src/components/AppModals.tsx @@ -39,6 +39,7 @@ export default function AppModals() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} + onClick={() => settleConfirm(false)} > {confirm.text}
- Promise; +const fileNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +export function sortImportFiles(files: File[]): File[] { + return [...files].sort((a, b) => fileNameCollator.compare(a.name, b.name)); +} + /** 导入弹窗共用的文件上传、任务收尾与汇总状态。 */ export function useImportWorkflow(onDone: () => void) { const [items, setItems] = useState([]); @@ -61,7 +67,7 @@ export function useImportWorkflow(onDone: () => void) { const selectFiles = useCallback( (files: File[]) => { reset(); - setItems(files.map((file) => ({ file, state: "pending" }))); + setItems(sortImportFiles(files).map((file) => ({ file, state: "pending" }))); }, [reset] ); @@ -103,7 +109,7 @@ export function useImportWorkflow(onDone: () => void) { ); const submit = useCallback( - async (upload: UploadAdapter) => { + async (upload: UploadAdapter, waitForQueued = false) => { if (items.length === 0 || submitting) return; clearTimer(); const run = ++runRef.current; @@ -119,8 +125,22 @@ export function useImportWorkflow(onDone: () => void) { const result = await upload(snapshot[index]); if (!mountedRef.current || run !== runRef.current) return; if (result.kind === "queued") { - jobs.push({ jobId: result.jobId, index }); updateItem(index, { state: "queued" }); + if (waitForQueued) { + while (mountedRef.current && run === runRef.current) { + const job = await api.getJob(result.jobId); + if (job.status === "done") { + updateItem(index, { state: "done" }); + break; + } + if (job.status === "error" || job.status === "cancelled") { + throw new Error(job.error ?? "任务未完成"); + } + await new Promise((resolve) => window.setTimeout(resolve, 250)); + } + } else { + jobs.push({ jobId: result.jobId, index }); + } } else { updateItem(index, { state: "done" }); } diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts index 5e845ac..e72d08d 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -272,7 +272,7 @@ export const en = { "msg.mark_keyframe": "Mark keyframe", "msg.material_deleted": "Material deleted", "msg.materials": "Materials", - "msg.materials_append_to_timeline_in_click_order": " materials (append to timeline in click order)", + "msg.materials_append_to_timeline_in_click_order": " materials (append to timeline by ascending frame number)", "msg.materials_empty": "Materials empty", "msg.materials_empty_generate_or_upload_in_materials_first": "Materials empty — generate or upload in Materials first", "msg.materials_empty_upload_or_ai_generate_some_first": "Materials empty — upload or AI-generate some first", diff --git a/apps/web/src/i18n/zh.ts b/apps/web/src/i18n/zh.ts index c0cff96..0a9cdb5 100644 --- a/apps/web/src/i18n/zh.ts +++ b/apps/web/src/i18n/zh.ts @@ -272,7 +272,7 @@ export const zh = { "msg.mark_keyframe": "标记关键帧", "msg.material_deleted": "已删除素材", "msg.materials": "素材库", - "msg.materials_append_to_timeline_in_click_order": "个素材(按点选顺序追加到时间轴末尾)", + "msg.materials_append_to_timeline_in_click_order": "个素材(按帧编号升序追加到时间轴末尾)", "msg.materials_empty": "素材库为空", "msg.materials_empty_generate_or_upload_in_materials_first": "素材库为空,先去素材库生成或上传素材", "msg.materials_empty_upload_or_ai_generate_some_first": "素材库为空,先上传或 AI 生成一些素材吧", diff --git a/tests/import-order.test.ts b/tests/import-order.test.ts new file mode 100644 index 0000000..b877fe2 --- /dev/null +++ b/tests/import-order.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test"; +import { sortImportFiles } from "../apps/web/src/hooks/useImportWorkflow"; + +describe("多文件导入顺序", () => { + test("按文件名自然升序排列数字帧", () => { + const files = ["run_12.png", "run_2.png", "run_1.png"].map((name) => new File([], name)); + expect(sortImportFiles(files).map((file) => file.name)).toEqual(["run_1.png", "run_2.png", "run_12.png"]); + }); +}); \ No newline at end of file diff --git a/tests/material-import-order.test.ts b/tests/material-import-order.test.ts new file mode 100644 index 0000000..43950c6 --- /dev/null +++ b/tests/material-import-order.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import type { MaterialRow } from "@framebaker/shared"; +import { sortMaterialsByFrameNumber } from "../apps/server/src/api/materials"; + +function material(name: string, createdAt: number): MaterialRow { + return { + id: name, + name, + raw_path: null, + processed_path: null, + status: "raw", + source: "extract", + folder_id: null, + metadata: "{}", + created_at: createdAt, + }; +} + +describe("素材导入顺序", () => { + test("忽略选择顺序并按帧编号自然升序排列", () => { + const selected = [material("run #120", 1), material("run #10", 2), material("run #2", 3), material("run #1", 4)]; + expect(sortMaterialsByFrameNumber(selected).map((item) => item.name)).toEqual([ + "run #1", + "run #2", + "run #10", + "run #120", + ]); + }); +}); \ No newline at end of file From c0cd3af7a85e46669ed3a84d5d53612fed659993 Mon Sep 17 00:00:00 2001 From: taotao7 Date: Sun, 9 Aug 2026 22:23:19 +0800 Subject: [PATCH 2/2] fix: preserve serial import ordering on polling failures --- apps/web/src/hooks/useImportWorkflow.ts | 65 ++++++++++++++++++++----- apps/web/src/i18n/en.ts | 1 + apps/web/src/i18n/zh.ts | 1 + tests/import-order.test.ts | 45 ++++++++++++++++- 4 files changed, 98 insertions(+), 14 deletions(-) diff --git a/apps/web/src/hooks/useImportWorkflow.ts b/apps/web/src/hooks/useImportWorkflow.ts index d00f642..e32d3b3 100644 --- a/apps/web/src/hooks/useImportWorkflow.ts +++ b/apps/web/src/hooks/useImportWorkflow.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { api } from "../api"; +import { api, type Job } from "../api"; +import { t } from "../i18n"; export type FileState = "pending" | "uploading" | "queued" | "done" | "error"; @@ -19,6 +20,37 @@ export function sortImportFiles(files: File[]): File[] { return [...files].sort((a, b) => fileNameCollator.compare(a.name, b.name)); } +type QueuedJobResult = + | { status: "done" } + | { status: "error" | "cancelled"; error: string | null } + | { status: "stale" }; + +/** 串行导入时等待任务结束;短暂查询失败只重试,不得放行下一文件。 */ +export async function waitForQueuedJob( + jobId: string, + isActive: () => boolean, + getJob: (id: string) => Promise> = api.getJob, + wait: () => Promise = () => new Promise((resolve) => window.setTimeout(resolve, 250)) +): Promise { + while (isActive()) { + let job: Pick; + try { + job = await getJob(jobId); + } catch { + if (!isActive()) return { status: "stale" }; + await wait(); + continue; + } + if (!isActive()) return { status: "stale" }; + if (job.status === "done") return { status: "done" }; + if (job.status === "error" || job.status === "cancelled") { + return { status: job.status, error: job.error }; + } + await wait(); + } + return { status: "stale" }; +} + /** 导入弹窗共用的文件上传、任务收尾与汇总状态。 */ export function useImportWorkflow(onDone: () => void) { const [items, setItems] = useState([]); @@ -121,32 +153,41 @@ export function useImportWorkflow(onDone: () => void) { for (let index = 0; index < snapshot.length; index++) { if (!mountedRef.current || run !== runRef.current) return; updateItem(index, { state: "uploading", error: null }); + let serialRequestSettled = !waitForQueued; try { const result = await upload(snapshot[index]); if (!mountedRef.current || run !== runRef.current) return; if (result.kind === "queued") { updateItem(index, { state: "queued" }); if (waitForQueued) { - while (mountedRef.current && run === runRef.current) { - const job = await api.getJob(result.jobId); - if (job.status === "done") { - updateItem(index, { state: "done" }); - break; - } - if (job.status === "error" || job.status === "cancelled") { - throw new Error(job.error ?? "任务未完成"); - } - await new Promise((resolve) => window.setTimeout(resolve, 250)); - } + const job = await waitForQueuedJob( + result.jobId, + () => mountedRef.current && run === runRef.current + ); + if (job.status === "stale") return; + serialRequestSettled = true; + if (job.status !== "done") throw new Error(job.error ?? "任务未完成"); + updateItem(index, { state: "done" }); } else { jobs.push({ jobId: result.jobId, index }); } } else { + serialRequestSettled = true; updateItem(index, { state: "done" }); } } catch (error) { if (!mountedRef.current || run !== runRef.current) return; updateItem(index, { state: "error", error: (error as Error).message }); + if (!serialRequestSettled) { + setItems((prev) => + prev.map((item, i) => + i > index && item.state === "pending" + ? { ...item, state: "error", error: t("msg.upload_result_unknown_remaining_stopped") } + : item + ) + ); + break; + } } } if (jobs.length === 0) complete(run); diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts index e72d08d..ac1a4e6 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -464,6 +464,7 @@ export const en = { "msg.upload_files": "Upload files", "msg.upload_materials": "Upload materials", "msg.upload_n_files": "Upload {n} files", + "msg.upload_result_unknown_remaining_stopped": "Previous upload result is unknown; remaining imports were stopped", "msg.uploading": "Uploading…", "msg.uploading_split_i_total": "Uploading split {i}/{total}", "msg.use_enhanced": "Use enhanced", diff --git a/apps/web/src/i18n/zh.ts b/apps/web/src/i18n/zh.ts index 0a9cdb5..5945665 100644 --- a/apps/web/src/i18n/zh.ts +++ b/apps/web/src/i18n/zh.ts @@ -464,6 +464,7 @@ export const zh = { "msg.upload_files": "上传文件", "msg.upload_materials": "上传素材", "msg.upload_n_files": "上传 {n} 个文件", + "msg.upload_result_unknown_remaining_stopped": "前一上传结果不确定,已停止后续导入", "msg.uploading": "上传中…", "msg.uploading_split_i_total": "切分上传中 {i}/{total}", "msg.use_enhanced": "用优化后的", diff --git a/tests/import-order.test.ts b/tests/import-order.test.ts index b877fe2..f9c5775 100644 --- a/tests/import-order.test.ts +++ b/tests/import-order.test.ts @@ -1,9 +1,50 @@ import { describe, expect, test } from "bun:test"; -import { sortImportFiles } from "../apps/web/src/hooks/useImportWorkflow"; +import { sortImportFiles, waitForQueuedJob } from "../apps/web/src/hooks/useImportWorkflow"; describe("多文件导入顺序", () => { test("按文件名自然升序排列数字帧", () => { const files = ["run_12.png", "run_2.png", "run_1.png"].map((name) => new File([], name)); expect(sortImportFiles(files).map((file) => file.name)).toEqual(["run_1.png", "run_2.png", "run_12.png"]); }); -}); \ No newline at end of file + + test("任务状态短暂查询失败时继续等待,不提前放行下一文件", async () => { + let calls = 0; + let waits = 0; + const result = await waitForQueuedJob( + "job-1", + () => true, + async () => { + calls++; + if (calls === 1) throw new Error("temporary network error"); + if (calls === 2) return { status: "running", error: null }; + return { status: "done", error: null }; + }, + async () => { + waits++; + } + ); + + expect(result).toEqual({ status: "done" }); + expect(calls).toBe(3); + expect(waits).toBe(2); + }); + + test("批次失效期间返回的旧任务结果会被丢弃", async () => { + let active = true; + let resolveJob!: (job: { status: "done"; error: null }) => void; + const resultPromise = waitForQueuedJob( + "job-1", + () => active, + () => + new Promise((resolve) => { + resolveJob = resolve; + }), + async () => {} + ); + + active = false; + resolveJob({ status: "done", error: null }); + + expect(await resultPromise).toEqual({ status: "stale" }); + }); +});