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)); +} + +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([]); @@ -61,7 +99,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 +141,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; @@ -115,18 +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") { - jobs.push({ jobId: result.jobId, index }); updateItem(index, { state: "queued" }); + if (waitForQueued) { + 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 5e845ac..ac1a4e6 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", @@ -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 c0cff96..5945665 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 生成一些素材吧", @@ -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 new file mode 100644 index 0000000..f9c5775 --- /dev/null +++ b/tests/import-order.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +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"]); + }); + + 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" }); + }); +}); 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