From 5a4375ba17656d7b245eee7f2196f2e71ce59ed9 Mon Sep 17 00:00:00 2001 From: YAN YIXIN Date: Thu, 5 Nov 2026 22:00:46 +0800 Subject: [PATCH 1/2] [test] browser: connect two images to a first-last video node Add an isolated browser check for #83. It uploads two real images, adds a blank video node on a MiniMax model that declares first and last frames, switches to first-last mode without touching the frame pickers, and drags both images onto the node on the canvas. It then checks that the composer shows the first and last frames in edge order, that the project stores first_frame and last_frame references, and that the assignment survives a reload. The check fails on the code before #98 (the last frame stays empty) and passes on main. No remote generation is called. Refs #83 Co-Authored-By: Claude Opus 5.5 --- docs/development/validation.md | 3 +- .../browser/first_last_frame_connections.mjs | 207 ++++++++++++++++++ 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 tests/browser/first_last_frame_connections.mjs diff --git a/docs/development/validation.md b/docs/development/validation.md index 715f30fc..3d03589d 100644 --- a/docs/development/validation.md +++ b/docs/development/validation.md @@ -121,9 +121,10 @@ AI 多会话检查使用同一隔离 API,但启动 `creation_server.py` 时额 python -m pytest tests/test_minimax_video.py tests/test_vllm_omni_h3.py tests/test_generation_errors.py -q node --test tests/creationParameters.test.ts node tests/browser/minimax_h3.mjs +node tests/browser/first_last_frame_connections.mjs ``` -浏览器用例沿用本文的隔离 API/Vite 环境,只验证模型配置、真实图片、首尾帧引用、槽位修改及重载,不调用官方服务。Python 用例使用真实素材和 HTTP 替身验证协议、冻结输入、恢复不重复提交、错误回执和无鉴权 CDN 下载,不能代替真实账户生成验证。 +浏览器用例沿用本文的隔离 API/Vite 环境,只验证模型配置、真实图片、首尾帧引用、槽位修改及重载,不调用官方服务。`first_last_frame_connections.mjs` 不操作首尾帧选择器,只在画布上依次拖线连接两张图片,核对声明尾帧的模型自动填入首帧与尾帧、项目保存对应的引用角色,并在重载后保持。Python 用例使用真实素材和 HTTP 替身验证协议、冻结输入、恢复不重复提交、错误回执和无鉴权 CDN 下载,不能代替真实账户生成验证。 ### 提供商与模型设置 diff --git a/tests/browser/first_last_frame_connections.mjs b/tests/browser/first_last_frame_connections.mjs new file mode 100644 index 00000000..0f67ea9a --- /dev/null +++ b/tests/browser/first_last_frame_connections.mjs @@ -0,0 +1,207 @@ +/** Connecting two images to a first-last video node through the canvas fills + * both frame slots when the model declares a last frame (#83). + * Uses the isolated creation_server.py; no remote generation. + */ +import assert from "node:assert/strict"; +import { launchBrowser } from "./engine.mjs"; +import { chooseGenerationModel } from "./model_picker.mjs"; + +const base = process.env.CREATION_WEB_URL ?? "http://127.0.0.1:5179"; +const api = process.env.CREATION_API_URL ?? "http://127.0.0.1:8019"; +const headers = { + "X-OpenDirector-Request": "1", + "Content-Type": "application/json", +}; +async function call(path, body) { + const response = await fetch(api + path, { + headers, + ...(body ? { method: "POST", body: JSON.stringify(body) } : {}), + }); + assert.ok(response.ok, await response.clone().text()); + return response.json(); +} +async function until(read, matches, label) { + const deadline = Date.now() + 15000; + while (true) { + const value = await read(); + if (matches(value)) return value; + assert.ok(Date.now() < deadline, `${label}: ${JSON.stringify(value)}`); + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +assert.equal((await call("/api/creation-test-health")).isolated, true); +const minimax = + (await call("/api/model-configs")).find( + (model) => model.modelId === "MiniMax-H3", + ) ?? + (await call("/api/model-configs", { + kind: "video", + url: "https://fixture.invalid/v2", + modelId: "MiniMax-H3", + apiKey: "fixture", + protocol: "minimax_video_v2", + connectionType: "api", + authMode: "bearer", + })); +assert.deepEqual(minimax.inputRules.frameRoles, ["first_frame", "last_frame"]); +const projectId = crypto.randomUUID(); +await call(`/api/creation/projects/${projectId}/commands`, { + commandId: crypto.randomUUID(), + projectId, + type: "project.create", + payload: { name: `First-last connections ${projectId.slice(0, 8)}` }, +}); +const snapshot = () => call(`/api/creation/projects/${projectId}`); + +const browser = await launchBrowser(); +const page = await browser.newPage({ viewport: { width: 1600, height: 1100 } }); +page.setDefaultTimeout(15000); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); +try { + await page.addInitScript(() => + localStorage.setItem("opendirector.modelSetup.v1", "dismissed"), + ); + await page.goto(`${base}/ai-tool/ai-canvas/${projectId}?creation=unified`); + await page + .getByText("已保存到本机", { exact: true }) + .waitFor({ state: "attached" }); + + // Two distinct real images, each uploaded as its own canvas node. + for (const [index, color] of ["#d33", "#36c"].entries()) { + const png = await page.evaluate((color) => { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 256; + const context = canvas.getContext("2d"); + context.fillStyle = color; + context.fillRect(0, 0, 256, 256); + return canvas.toDataURL("image/png").split(",")[1]; + }, color); + await page.getByLabel("选择本地素材文件", { exact: true }).setInputFiles({ + name: `frame-${index + 1}.png`, + mimeType: "image/png", + buffer: Buffer.from(png, "base64"), + }); + await until( + snapshot, + (value) => value.canvasItems.length === index + 1, + "image uploaded", + ); + } + const images = (await snapshot()).canvasItems.map((item) => item.id); + + // A blank video node on the MiniMax model in first-last mode. The frame + // pickers are left untouched: the connections alone must fill them. + await page + .getByRole("toolbar", { name: "画布创作工具" }) + .getByRole("button", { name: "添加视频", exact: true }) + .click(); + const created = await until( + snapshot, + (value) => value.canvasItems.length === 3, + "video node created", + ); + const videoId = created.canvasItems.find( + (item) => !images.includes(item.id), + ).id; + const picked = await page + .getByRole("combobox", { name: "生成模型", exact: true }) + .textContent(); + if (!picked.includes("MiniMax-H3")) + await chooseGenerationModel(page, { label: "MiniMax-H3" }); + await page + .getByLabel("视频创作模式", { exact: true }) + .selectOption("first-last"); + await until( + snapshot, + (value) => + value.generationSpecs.some( + (spec) => + spec.modelConfigId === minimax.id && + spec.parameters?.videoMode === "first_frame", + ), + "first-last mode saved", + ); + + // Lay the nodes out, then drag A → video and B → video on the canvas. + const node = (id) => page.locator(`.react-flow__node[data-id="${id}"]`); + const place = async (id, x, y) => { + const box = await node(id).boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + 12); + await page.mouse.down(); + await page.mouse.move(x, y, { steps: 10 }); + await page.mouse.up(); + }; + await page.keyboard.press("Escape"); + await place(images[0], 300, 300); + await place(images[1], 300, 750); + await place(videoId, 1050, 520); + for (const source of images) { + const box = await node(source).boundingBox(); + await page.mouse.move(box.x + box.width + 7, box.y + box.height / 2); + const port = page.getByRole("button", { + name: "添加下游参考节点", + exact: true, + }); + await port.waitFor(); + const from = await port.boundingBox(); + const to = await node(videoId).boundingBox(); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { + steps: 15, + }); + await page.mouse.up(); + await page.waitForTimeout(400); + } + await page.waitForFunction( + () => document.querySelectorAll(".react-flow__edge").length === 2, + ); + + // The composer shows A then B, and the project stores the matching roles. + await node(videoId).click(); + const first = page.getByLabel("选择首帧", { exact: true }); + const last = page.getByLabel("选择尾帧", { exact: true }); + await until( + () => last.inputValue(), + (value) => value === images[1], + "last frame slot holds the second image", + ); + assert.equal(await first.inputValue(), images[0]); + const saved = await until( + snapshot, + (value) => + value.references + .filter((ref) => ref.active) + .map((ref) => ref.role) + .sort() + .join() === "first_frame,last_frame", + "references saved as first_frame and last_frame", + ); + const role = (id) => + saved.references.find((ref) => ref.active && ref.source?.id === id)?.role; + assert.equal(role(images[0]), "first_frame"); + assert.equal(role(images[1]), "last_frame"); + + // The assignment survives a reload. + await page.reload(); + await page + .getByText("已保存到本机", { exact: true }) + .waitFor({ state: "attached" }); + await node(videoId).click(); + await until( + () => page.getByLabel("选择尾帧", { exact: true }).inputValue(), + (value) => value === images[1], + "last frame after reload", + ); + assert.deepEqual(errors, []); + console.log("first/last frame connections ok"); +} catch (error) { + await page + .screenshot({ path: ".opendirector/first-last-connections-failure.png" }) + .catch(() => {}); + throw error; +} finally { + await browser.close(); +} From d6dcaaac59e0dfa6a08f82505991e7b4c0ca9cf4 Mon Sep 17 00:00:00 2001 From: YAN YIXIN Date: Thu, 5 Nov 2026 22:53:58 +0800 Subject: [PATCH 2/2] [test] browser: pick the MiniMax config by id in the frame check Other browser checks register their own MiniMax-H3 configurations on the shared fixture, so choosing the model by its label matched several options and the picker click failed in strict mode. Select this check's own configuration by its option id instead. Co-Authored-By: Claude Opus 5.5 --- tests/browser/first_last_frame_connections.mjs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/browser/first_last_frame_connections.mjs b/tests/browser/first_last_frame_connections.mjs index 0f67ea9a..b5e90cc2 100644 --- a/tests/browser/first_last_frame_connections.mjs +++ b/tests/browser/first_last_frame_connections.mjs @@ -105,11 +105,9 @@ try { const videoId = created.canvasItems.find( (item) => !images.includes(item.id), ).id; - const picked = await page - .getByRole("combobox", { name: "生成模型", exact: true }) - .textContent(); - if (!picked.includes("MiniMax-H3")) - await chooseGenerationModel(page, { label: "MiniMax-H3" }); + // Other checks may register their own MiniMax-H3 configs on the shared + // fixture, so pick this exact configuration by id rather than by name. + await chooseGenerationModel(page, `config:${minimax.id}`); await page .getByLabel("视频创作模式", { exact: true }) .selectOption("first-last");