feat(canvas): 多模态表格 - 合并 PR #549 并保留本地交互与校验
This commit is contained in:
35
web/test/canvas-batch-storyboard.test.ts
Normal file
35
web/test/canvas-batch-storyboard.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { bindStoryboardKeyframes, storyboardRowsFromBatchTable } from "@/lib/canvas/canvas-batch-storyboard";
|
||||
import { createCanvasNode, createStoryboardRow } from "@/lib/canvas/canvas-project-domain";
|
||||
import { CanvasNodeType, type CanvasBatchTableData } from "@/types/canvas";
|
||||
|
||||
test("storyboard conversion follows row identity and retains edits and asset bindings", () => {
|
||||
const table: CanvasBatchTableData = {
|
||||
operation: "creative", concurrency: 1,
|
||||
textColumns: [{ id: "unrelated", label: "无关列" }],
|
||||
rows: [
|
||||
{ id: "second", enabled: true, prompt: "", inputNodeIds: [], cells: { "storyboard-shotNumber": "72", "storyboard-durationSeconds": "2.5", "storyboard-dialogue": "" } },
|
||||
{ id: "new", enabled: true, prompt: "", inputNodeIds: [], cells: { unrelated: "不要猜成画面" } },
|
||||
{ id: "first", enabled: false, prompt: "", inputNodeIds: [] },
|
||||
],
|
||||
storyboardRows: [
|
||||
createStoryboardRow(1, { id: "first", plotDescription: "deleted" }),
|
||||
createStoryboardRow(2, { id: "second", plotDescription: "second shot", dialogue: "old", keyframeTimeMs: 2000, imageNodeId: "existing-image" }),
|
||||
],
|
||||
};
|
||||
const rows = storyboardRowsFromBatchTable(table);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({ id: "second", shotNumber: 72, durationSeconds: 2.5, dialogue: "", plotDescription: "second shot", keyframeTimeMs: 2000, imageNodeId: "existing-image" });
|
||||
expect(rows[1].plotDescription).toBe("");
|
||||
});
|
||||
|
||||
test("keyframes bind by source and timestamp despite sorting, duplicates or partial extraction", () => {
|
||||
const rows = [3000, 1000, 3000, 2000].map((time, index) => createStoryboardRow(index + 1, { keyframeTimeMs: time }));
|
||||
const frame = (source: string, time: number) => createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { videoFrameSourceNodeId: source, videoFrameTimeMs: time });
|
||||
const first = frame("video", 1000);
|
||||
const third = frame("video", 3000);
|
||||
const result = bindStoryboardKeyframes(rows, [first, third, frame("other", 2000)], "video");
|
||||
expect(result.map((row) => row.imageNodeId)).toEqual([third.id, first.id, third.id, rows[3].imageNodeId]);
|
||||
expect(result[3]).toBe(rows[3]);
|
||||
expect(bindStoryboardKeyframes([createStoryboardRow(1, { sourceStartMs: 500, sourceEndMs: 1500 })], [first], "video")[0].imageNodeId).toBe(first.id);
|
||||
});
|
||||
40
web/test/canvas-batch-table-merge.test.tsx
Normal file
40
web/test/canvas-batch-table-merge.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
for (const mode of ["light", "dark"] as const) {
|
||||
test(`merged batch table keeps local controls and dynamic cells in ${mode} mode`, () => {
|
||||
const output = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { content: "data:image/png;base64,AA==" });
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 0, y: 0 }, {
|
||||
batchTable: {
|
||||
operation: "creative", concurrency: 1, aiGenerated: true, contentKind: "storyboard",
|
||||
textColumns: [{ id: "detail", label: "画面细节" }],
|
||||
referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }],
|
||||
rows: [{ id: "row", enabled: true, inputNodeIds: [], prompt: "@参考图1 保留提示", cells: { detail: "已编辑内容" }, outputNodeId: output.id }],
|
||||
},
|
||||
});
|
||||
const render = (readOnly = false) => renderToStaticMarkup(<CanvasBatchTableNodeContent
|
||||
node={node} nodes={[node, output]} connections={[]} theme={canvasThemes[mode]} readOnly={readOnly}
|
||||
onPatchTable={noop} onAddRow={noop} onRemoveRow={noop} onUpdateRow={noop} onFillRows={noop}
|
||||
onGenerate={noop} onCreateStoryboard={noop} onRetryItem={noop} onAddReferenceColumn={noop}
|
||||
onRemoveReferenceColumn={noop} onFocusOutput={noop} onConnectStart={noop}
|
||||
/>);
|
||||
const editable = render();
|
||||
expect(editable).toContain('aria-label="减少一组参考图"');
|
||||
expect(editable).toContain('aria-label="任务 1 提示词"');
|
||||
expect(editable).toContain("点击定位到画布节点");
|
||||
expect(editable).toContain("画面细节");
|
||||
expect(editable).toContain("已编辑内容");
|
||||
expect(editable).toContain("创建视频脚本");
|
||||
const readOnly = render(true);
|
||||
expect(readOnly).not.toContain('aria-label="减少一组参考图"');
|
||||
for (const textarea of readOnly.match(/<textarea\b[^>]*>/g) || []) expect(textarea).toContain('readOnly=""');
|
||||
expect(readOnly).not.toContain("创建视频脚本");
|
||||
expect(readOnly).toContain("已编辑内容");
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
batchGenerationRows,
|
||||
batchRowReady,
|
||||
batchPromptForRow,
|
||||
batchReferenceColumns,
|
||||
batchReferenceHandleAtY,
|
||||
@@ -21,6 +23,32 @@ import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
describe("batch creation table", () => {
|
||||
test("bulk skips persisted results while explicit regeneration remains available", () => {
|
||||
const input = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { storageKey: "input" });
|
||||
const output = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { storageKey: "output" });
|
||||
const row = { id: "row", enabled: true, inputNodeIds: [input.id, ""], prompt: "create", outputNodeId: output.id };
|
||||
const table = { operation: "creative" as const, concurrency: 1, rows: [row] };
|
||||
const source = createCanvasNode(CanvasNodeType.BatchTable, { x: 0, y: 0 }, { batchTable: table });
|
||||
const nodes = [source, input, output];
|
||||
expect(batchGenerationRows(source, nodes)).toEqual([]);
|
||||
expect(batchGenerationRows(source, nodes, [row.id])).toEqual([row]);
|
||||
expect(batchGenerationRows(source, nodes, [])).toEqual([]);
|
||||
expect(batchGenerationRows(source, [source, output], [row.id])).toEqual([]);
|
||||
expect(batchRowReady({ ...row, enabled: false }, table, new Map(nodes.map((node) => [node.id, node])))).toBe(false);
|
||||
expect(batchGenerationRows(source, nodes.map((node) => node.id === input.id ? { ...node, type: CanvasNodeType.Video } : node), [row.id])).toEqual([]);
|
||||
source.metadata!.generationBatches = [{ id: "batch", projectId: "project", sourceNodeId: source.id, mode: "batch_image", status: "running", createdAt: "", updatedAt: "", items: [{ id: "item", rowId: row.id, nodeId: output.id, retryCount: 0, status: "running" }] }];
|
||||
expect(batchGenerationRows(source, nodes, [row.id])).toEqual([]);
|
||||
});
|
||||
|
||||
test("sync preserves dynamic cells, text references and distinct manual row identities", () => {
|
||||
const first = { id: "first", enabled: true, inputNodeIds: ["image", "old"], prompt: "first", cells: { detail: "edited" }, textNodeIds: ["text"] };
|
||||
const second = { ...first, id: "second", prompt: "manual" };
|
||||
const rows = createBatchRowsFromColumns("creative", [["image"], ["new"]], [first, second]);
|
||||
expect(rows.map((row) => row.id)).toEqual(["first", "second"]);
|
||||
expect(rows[0].cells).toEqual(first.cells);
|
||||
expect(rows[0].textNodeIds).toEqual(first.textNodeIds);
|
||||
expect(rows[1]).toEqual(second);
|
||||
});
|
||||
test("creates one try-on row per person with the final image as the shared garment", () => {
|
||||
const rows = createBatchRowsFromInputs("try_on", ["person-1", "person-2", "person-3", "garment"]);
|
||||
|
||||
|
||||
57
web/test/list-mode-generator.test.ts
Normal file
57
web/test/list-mode-generator.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { parseListModeJson, parseStoryboardJson, requestedListRowCount } from "@/pages/canvas/list-mode-generator";
|
||||
|
||||
test("extracts the requested row count from natural language", () => {
|
||||
expect(requestedListRowCount("帮我生成5个卖点")).toBe(5);
|
||||
expect(requestedListRowCount("请写十条小红书标题")).toBe(10);
|
||||
expect(requestedListRowCount("分析这张图")).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("list mode response parsing", () => {
|
||||
test("rejects invalid rows instead of creating an empty success table", () => {
|
||||
for (const rows of [[], [null, 1, "bad", []]]) {
|
||||
expect(parseListModeJson(JSON.stringify({ columns: ["detail"], rows }))).toBeNull();
|
||||
expect(parseStoryboardJson(JSON.stringify({ rows }))).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not turn missing or invalid timestamps into a first-frame request", () => {
|
||||
for (const time of [null, "", " ", false, -1, "invalid"]) {
|
||||
const parsed = parseStoryboardJson(JSON.stringify({ rows: [{ plotDescription: "shot", keyframeTimeSeconds: time }] }));
|
||||
expect(parsed?.rows[0].keyframeTimeMs).toBeUndefined();
|
||||
}
|
||||
expect(parseStoryboardJson('{"rows":[{"keyframeTimeSeconds":0}]}')?.rows[0].keyframeTimeMs).toBe(0);
|
||||
});
|
||||
test("parses plain JSON", () => {
|
||||
expect(parseListModeJson('{"columns":["卖点"],"rows":[{"卖点":"轻便"}]}')).toEqual({
|
||||
columns: ["卖点"],
|
||||
rows: [{ 卖点: "轻便" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("parses markdown JSON with surrounding explanation", () => {
|
||||
expect(parseListModeJson('结果如下:\n```json\n{"columns":["标题"],"rows":[{"标题":"新品"}]}\n```\n')).toEqual({
|
||||
columns: ["标题"],
|
||||
rows: [{ 标题: "新品" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes duplicate columns and non-string cells", () => {
|
||||
expect(parseListModeJson('{"columns":["卖点","卖点","细节"],"rows":[{"卖点":["轻","薄"],"细节":123}]}')).toEqual({
|
||||
columns: ["卖点", "细节"],
|
||||
rows: [{ 卖点: "轻、薄", 细节: "123" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects malformed responses", () => {
|
||||
expect(parseListModeJson("模型暂时无法分析")).toBeNull();
|
||||
expect(parseListModeJson('{"columns":[],"rows":[]}')).toBeNull();
|
||||
});
|
||||
|
||||
test("parses a storyboard response and derives generation prompts", () => {
|
||||
const result = parseStoryboardJson('```json\n{"title":"拆解","rows":[{"durationSeconds":3,"plotDescription":"产品推近","motion":"缓慢推进","keyframeTimeSeconds":1.5}]}\n```');
|
||||
expect(result?.title).toBe("拆解");
|
||||
expect(result?.rows[0]).toMatchObject({ durationSeconds: 3, plotDescription: "产品推近", motion: "缓慢推进", videoMotionPrompt: "产品推近;缓慢推进", keyframeTimeMs: 1500 });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,14 @@ import test from "node:test";
|
||||
// Bun 直接执行 TypeScript 测试时需要保留扩展名;生产 tsconfig 不包含 test/。
|
||||
import { DEFAULT_VIDEO_PROMPT_MAX_CHARS, defaultModelCapabilityConfig, normalizeVideoValue } from "../src/lib/model-capabilities.ts";
|
||||
|
||||
test("text multimodal capability is not guessed from a model name", () => {
|
||||
for (const model of ["gpt-4o", "gemini-2.5-pro", "doubao-seed"]) {
|
||||
const text = defaultModelCapabilityConfig(undefined, model).text!;
|
||||
assert.equal(text.references.maxImages, 0);
|
||||
assert.equal(text.references.maxVideos, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("switching to MiniMax H3 replaces an unsupported 720p value with 768P", () => {
|
||||
const profile = defaultModelCapabilityConfig("minimax-video", "MiniMax-H3").video!;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user