Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions apps/server/src/api/materials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -358,17 +369,18 @@ export const materialsApi = new Elysia({ prefix: "/api" })
},
{ body: t.Object({ ids: t.Array(t.String()) }) }
)
// 批量导入到项目(保持给定顺序,各 1 份)
// 批量导入到项目(按素材名称中的帧编号自然升序,各 1 份)
.post(
"/materials/batch-import",
({ body, status }) => {
const project = db.query("SELECT id FROM projects WHERE id = ?").get(body.projectId);
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++;
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/AppModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default function AppModals() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => settleConfirm(false)}
>
<motion.div
className="modal pixel-panel confirm-modal"
Expand All @@ -52,7 +53,7 @@ export default function AppModals() {
<span>{confirm.text}</span>
</div>
<div className="modal-actions">
<button type="button" className="px-btn" >
<button type="button" className="px-btn" onClick={() => settleConfirm(false)}>
{t("common.cancel")}
</button>
<motion.button
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export default function ImportModal({ projectId, onClose, onDone }: Props) {
fd.append("autoMatting", String(autoMatting));
const { jobId } = await api.upload(fd);
return { kind: "queued", jobId };
});
}, true);
};

// ---- 生成 Tab:提交即关窗,进度与结果由右侧任务面板展示 ----
Expand Down
69 changes: 65 additions & 4 deletions apps/web/src/hooks/useImportWorkflow.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -13,6 +14,43 @@ export interface UploadItem {
export type UploadResult = { kind: "done" } | { kind: "queued"; jobId: string };
export type UploadAdapter = (file: File) => Promise<UploadResult>;

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<Pick<Job, "status" | "error">> = api.getJob,
wait: () => Promise<void> = () => new Promise((resolve) => window.setTimeout(resolve, 250))
): Promise<QueuedJobResult> {
while (isActive()) {
let job: Pick<Job, "status" | "error">;
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<UploadItem[]>([]);
Expand Down Expand Up @@ -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]
);
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 生成一些素材吧",
Expand Down Expand Up @@ -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": "用优化后的",
Expand Down
50 changes: 50 additions & 0 deletions tests/import-order.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
29 changes: 29 additions & 0 deletions tests/material-import-order.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
Loading