feat(*): 分镜与资产 - 完善五类资产、候选去重、搜索、中文镜头编号与分类整理

This commit is contained in:
ddCat committed 2026-08-31 13:16:10 +08:00
1 parent ce6e20f749
commit f548681787
53 files changed
+816 -188

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { assetCategoryLabel, defaultAssetCategoryForKind, normalizeAssetCategory } from "@/lib/asset-category";
describe("asset category contract", () => {
test("旧服饰、武器和配饰统一迁移为道具", () => {
expect(normalizeAssetCategory("wardrobe")).toBe("prop");
expect(normalizeAssetCategory("weapon")).toBe("prop");
expect(normalizeAssetCategory("accessory")).toBe("prop");
});
test("旧画风迁移为素材,未知值归入其他", () => {
expect(normalizeAssetCategory("style")).toBe("material");
expect(normalizeAssetCategory("unknown")).toBe("other");
expect(assetCategoryLabel("material")).toBe("素材");
});
test("未分类媒体默认归入素材", () => {
expect(defaultAssetCategoryForKind("image")).toBe("material");
expect(defaultAssetCategoryForKind("video")).toBe("material");
expect(defaultAssetCategoryForKind("audio")).toBe("material");
expect(defaultAssetCategoryForKind("text")).toBe("other");
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import { canvasLayoutLane, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodesByMediaType } from "@/lib/canvas/canvas-layout";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
function node(id: string, type: CanvasNodeType, width = 320, height = 180, locked = false): CanvasNodeData {
return { id, type, title: id, position: { x: 100, y: 100 }, width, height, metadata: locked ? { locked: true } : undefined };
}
describe("canvas media-aware layout", () => {
test("无连线时按文本、图片、视频、音频分成纵向泳道", () => {
const text = node("text", CanvasNodeType.Text, 300, 120);
const image = node("image", CanvasNodeType.Image, 420, 260);
const video = node("video", CanvasNodeType.Video, 500, 280);
const audio = node("audio", CanvasNodeType.Audio, 360, 100);
const positions = layoutCanvasNodesByMediaType([video, text, audio, image]);
expect(positions.get("text")!.y).toBeLessThan(positions.get("image")!.y);
expect(positions.get("image")!.y).toBeLessThan(positions.get("video")!.y);
expect(positions.get("video")!.y).toBeLessThan(positions.get("audio")!.y);
expect(positions.get("image")!.y).toBeGreaterThanOrEqual(positions.get("text")!.y + text.height);
});
test("有连线时保持从左到右的拓扑层,同时分离媒体泳道", () => {
const text = node("text", CanvasNodeType.Text);
const image = node("image", CanvasNodeType.Image);
const video = node("video", CanvasNodeType.Video);
const connections: CanvasConnection[] = [
{ id: "a", fromNodeId: text.id, toNodeId: image.id },
{ id: "b", fromNodeId: image.id, toNodeId: video.id },
];
const positions = layoutCanvasFlow([video, image, text], connections);
expect(positions.get("text")!.x).toBeLessThan(positions.get("image")!.x);
expect(positions.get("image")!.x).toBeLessThan(positions.get("video")!.x);
expect(positions.get("text")!.y).toBeLessThan(positions.get("image")!.y);
expect(positions.get("image")!.y).toBeLessThan(positions.get("video")!.y);
});
test("同一拓扑层和媒体泳道中的不同尺寸节点不会重叠", () => {
const first = node("first", CanvasNodeType.Image, 420, 300);
const second = node("second", CanvasNodeType.Image, 240, 120);
const positions = layoutCanvasFlow([first, second], []);
expect(positions.get("second")!.y).toBeGreaterThanOrEqual(positions.get("first")!.y + first.height);
});
test("自动整理只返回可移动节点,并忽略选区外连接", () => {
const image = node("image", CanvasNodeType.Image);
const video = node("video", CanvasNodeType.Video);
const locked = node("locked", CanvasNodeType.Image, 320, 180, true);
const frame = node("frame", CanvasNodeType.Frame);
const positions = layoutCanvasAuto([image, video, locked, frame], [{ id: "external", fromNodeId: image.id, toNodeId: "outside" }]);
expect([...positions.keys()].sort()).toEqual(["image", "video"]);
expect(positions.get("image")!.y).toBeLessThan(positions.get("video")!.y);
});
test("节点类型映射为稳定的四类泳道", () => {
expect(canvasLayoutLane(node("script", CanvasNodeType.Script))).toBe("text");
expect(canvasLayoutLane(node("drawing", CanvasNodeType.Drawing))).toBe("image");
expect(canvasLayoutLane(node("video", CanvasNodeType.Video))).toBe("video");
expect(canvasLayoutLane(node("audio", CanvasNodeType.Audio))).toBe("audio");
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { customShotTitle, formatShotOrdinal, normalizeDefaultShotTitle } from "@/lib/shot-label";
describe("shot labels", () => {
test("使用中文两位镜头编号", () => {
expect(formatShotOrdinal(0)).toBe("镜头01");
expect(formatShotOrdinal(11)).toBe("镜头12");
});
test("只转换精确匹配的历史默认标题", () => {
expect(normalizeDefaultShotTitle("SC.01", 0)).toBe("镜头01");
expect(normalizeDefaultShotTitle("SC.01 雨夜", 0)).toBe("SC.01 雨夜");
expect(normalizeDefaultShotTitle("雨夜追逐", 0)).toBe("雨夜追逐");
});
test("默认标题不会在编号旁重复展示", () => {
expect(customShotTitle("SC.01", 0)).toBe("");
expect(customShotTitle("镜头01", 0)).toBe("");
expect(customShotTitle("雨夜追逐", 0)).toBe("雨夜追逐");
});
});