fix(HEL-751): restore confirmed full-width task table

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
leefer
2026-10-22 05:18:07 +08:00
co-authored by multica-agent
parent 54880e900b
commit dca88b3765
6 changed files with 1089 additions and 159 deletions
@@ -138,7 +138,7 @@
overflow: auto;
}
.mg-table table {
table-layout: fixed;
table-layout: var(--manage-table-layout, fixed);
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
@@ -148,6 +148,7 @@
top: 0;
background: var(--bg-0);
z-index: 1;
box-sizing: var(--manage-cell-box, content-box);
text-align: left;
font-weight: 500;
color: var(--tx-3);
@@ -156,6 +157,7 @@
border-bottom: 1px solid var(--line);
}
.mg-table td {
box-sizing: var(--manage-cell-box, content-box);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -752,3 +754,84 @@ textarea.f-input {
.mg-pin[aria-pressed="true"] svg {
fill: currentColor;
}
/* Confirmed full-width task table. Shared table/controls remain the single source. */
.task-table {
--manage-table-layout: var(--task-table-layout);
--manage-cell-box: border-box;
}
.task-thumbnail-column {
width: var(--task-thumbnail-column);
}
.task-progress-column {
width: var(--task-progress-column);
}
.task-tabs {
width: var(--task-tabs-width);
margin-left: var(--task-tabs-offset);
}
.task-search {
width: var(--task-search-width);
}
.task-thumbnail {
display: grid;
place-items: center;
width: var(--task-thumbnail-width);
height: var(--task-thumbnail-height);
border-radius: var(--task-thumbnail-radius);
border: 0;
padding: 0;
overflow: hidden;
color: var(--tx-3);
background: var(--bg-2);
cursor: pointer;
}
.task-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.task-name {
font-weight: 500;
}
.task-progress {
height: var(--task-progress-height);
margin-top: var(--task-progress-gap);
border-radius: var(--task-progress-radius);
background: rgb(var(--ink) / 0.1);
overflow: hidden;
}
.task-progress i {
display: block;
height: 100%;
background: var(--task-progress-fill);
box-shadow: var(--task-progress-shadow);
transition: width var(--task-progress-duration) linear;
}
.task-progress-unknown {
color: var(--tx-3);
margin-left: var(--task-progress-gap);
}
.task-table .mg-tag.run,
.task-dialog .mg-tag.run {
color: var(--task-running-color);
border-color: var(--task-running-border);
background: var(--task-running-background);
}
.task-dialog {
height: auto;
overflow: visible;
gap: 12px;
}
.task-output {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-output img,
.task-output video {
width: 100%;
max-height: var(--task-preview-height);
object-fit: contain;
background: var(--media-bg);
}
+250 -158
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { SvelteMap } from "svelte/reactivity";
import Modal from "$lib/components/ui/Modal.svelte";
import { resolve } from "$app/paths";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
@@ -14,7 +16,7 @@
} from "$lib/api";
import { connectEvents } from "$lib/events";
import { parseDbTs } from "$lib/dbtime";
import { handleJobEvent, progressFor } from "$lib/jobProgress";
import { handleJobEvent } from "$lib/jobProgress";
import { jobStatusInfo } from "$lib/jobStatus";
import { isFileMissing, markOutputMissing } from "$lib/missingOutputs.svelte";
import { toast } from "$lib/toast";
@@ -26,7 +28,7 @@
} from "$lib/jobLabels";
import { createTakeDeleteQueue } from "$lib/components/generate/takeDelete.svelte";
/** Three-column real tasks: source filters, queue list and details. Existing SSE, retry, cancellation and undo-delete behavior is preserved. */
/** Full-width confirmed task table; existing mutations stay in the shared APIs. */
const client = useQueryClient();
const jobsQuery = createQuery({
@@ -43,9 +45,27 @@
queryFn: projects.list,
});
// Only explicit, attributed numeric reports are percentages. The shared
// progress store also estimates ticks for other consumers; do not show those here.
const reportedProgress = new SvelteMap<number, number>();
$effect(() => {
const off = connectEvents((ev) => {
handleJobEvent(ev);
if (ev.type === "job.progress" && typeof ev.data.job_id === "number") {
const value = ev.data.progress ?? ev.data.percent ?? ev.data.value;
if (typeof value === "number" && Number.isFinite(value)) {
reportedProgress.set(
ev.data.job_id,
Math.max(0, Math.min(100, value <= 1 ? value * 100 : value)),
);
}
}
if (
["job.completed", "job.failed", "job.deleted"].includes(ev.type) &&
typeof ev.data.job_id === "number"
) {
reportedProgress.delete(ev.data.job_id);
}
if (ev.type.startsWith("job.") || ev.type === "events.resync") {
void client.invalidateQueries({ queryKey: ["jobs"] });
void client.invalidateQueries({ queryKey: ["playground-jobs"] });
@@ -83,16 +103,11 @@
)
return false;
if (!q) return true;
return `${wfName(j)} ${jobSourceLabel(j)} ${j.error ?? ""} ${j.id}`
return `${taskName(j)} ${wfName(j)} ${projectOf(j)} ${jobSourceLabel(j)} ${connectionOf(j)} ${j.error ?? ""} ${j.id}`
.toLowerCase()
.includes(q);
});
});
const activeCount = $derived(
genJobs.filter((j) => j.status === "pending" || j.status === "running")
.length,
);
function wfName(job: Job): string {
return (
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)
@@ -105,7 +120,17 @@
const title = ($projectsQuery.data ?? []).find(
(p) => p.id === job.project_id,
)?.title;
return title ? `《${title}》` : "";
return title ? `项目 · ${title}` : "";
}
function sourceOf(job: Job): string {
if (isWorkflowTestJob(job)) return "工作流试跑";
if (
job.payload?.source === "studio-image" ||
job.payload?.source === "studio-video"
)
return "创作台";
return projectOf(job) || jobSourceLabel(job);
}
function timeOf(job: Job): string {
@@ -113,7 +138,7 @@
if (!Number.isFinite(ts)) return "—";
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
function toneOf(job: Job, base: string): string {
@@ -124,9 +149,19 @@
}
function labelOf(job: Job, base: { tone: string; label: string }): string {
return base.tone === "ok" && isFileMissing(job)
? t("jobst.missing")
: base.label;
if (base.tone === "ok" && isFileMissing(job)) return t("jobst.missing");
if (base.tone === "ok") return "完成";
if (job.status === "pending") return "排队";
if (job.status === "running" && job.phase === "comfy_running")
return "生成中";
if (
base.tone === "fail" &&
job.error &&
job.error !== "cancelled" &&
job.error.length <= 16
)
return `失败 · ${job.error}`;
return base.label;
}
function requestDelete(job: Job) {
@@ -172,6 +207,7 @@
let sourceFilter = $state("all");
let activeId = $state<number | null>(null);
let detailOpen = $state(false);
function matchesSource(job: Job, value: string) {
if (value === "all") return true;
if (value === "test") return isWorkflowTestJob(job);
@@ -180,6 +216,26 @@
? isStandaloneJob(job)
: !isStandaloneJob(job);
}
function taskName(job: Job): string {
return typeof job.payload?.title === "string" && job.payload.title.trim()
? job.payload.title
: `${job.kind === "image" ? "图片任务" : "视频任务"} #${job.id}`;
}
function connectionOf(job: Job): string {
const snapshot = job.payload?.endpoint;
if (
snapshot &&
typeof snapshot === "object" &&
"name" in snapshot &&
typeof snapshot.name === "string"
)
return snapshot.name;
return job.status === "pending" ? "等待分配" : "未记录";
}
function openDetail(job: Job) {
activeId = job.id;
detailOpen = true;
}
const activeJob = $derived(
genJobs.find((j) => j.id === activeId && !pendingIds.has(j.id)) ?? null,
);
@@ -193,7 +249,11 @@
onclick={() => (filter = value as Filter)}>{label}</button
>{/each}
</div>{/snippet}
{#snippet actions()}<label class="mg-search task-search"
{#snippet actions()}{#if sourceFilter !== "all"}<Button
size="sm"
variant="ghost"
onclick={() => (sourceFilter = "all")}>全部来源</Button
>{/if}<label class="mg-search task-search"
><Icon name="search-manage" size={14} /><input
type="search"
bind:value={search}
@@ -201,151 +261,183 @@
aria-label="搜索任务…"
/></label
>{/snippet}
<div class="mg-layout">
<aside class="mg-side" aria-label="任务来源">
<div class="mg-label">来源</div>
{#each [["all", "全部任务"], ["standalone", "创作台"], ["project", "项目"]] as [value, label] (value)}<button
class="mg-filter"
class:on={sourceFilter === value}
onclick={() => (sourceFilter = value)}
>{label}<small
>{genJobs.filter((j) => matchesSource(j, value)).length}</small
></button
>{/each}
<div class="mg-label">队列</div>
<span class="mg-dim">进行中 {activeCount} 项</span>
</aside>
<div class="mg-main">
<div class="mg-table">
<table>
<colgroup
><col style="width:42%" /><col style="width:20%" /><col
style="width:20%"
/><col style="width:18%" /></colgroup
><thead
><tr
><th>任务 / 工作流</th><th>来源</th><th>进度</th><th>时间</th></tr
></thead
><tbody>
{#each shown as job (job.id)}{@const st = jobStatusInfo(job)}
<tr class:on={activeId === job.id}
><td
><button class="mg-click" onclick={() => (activeId = job.id)}
><Icon
name={job.kind === "image" ? "image" : "film-manage"}
size={14}
/><span class="mg-name"
><b>{wfName(job)}</b><small
>#{job.id}{isWorkflowTestJob(job)
? " · 工作流试跑"
: ""}</small
></span
></button
></td
><td title={projectOf(job) || jobSourceLabel(job)}
>{projectOf(job) || jobSourceLabel(job)}</td
><td
><span class="mg-tag {toneOf(job, st.tone)}" title={st.detail}
>{labelOf(job, st)}</span
>{#if job.status === "running" && progressFor(job.id)?.progress != null}<span
class="mg-dim">{progressFor(job.id)?.progress}%</span
>{/if}</td
><td class="mg-mono">{timeOf(job)}</td></tr
<div class="mg-table task-table">
<table aria-label="生成任务">
<thead
><tr
><th class="task-thumbnail-column" aria-label="缩略图"></th><th
>任务</th
><th>来源</th><th>工作流</th><th>连接</th><th
class="task-progress-column">进度</th
><th>时间</th><th aria-label="操作"></th></tr
></thead
>
<tbody>
{#each shown as job (job.id)}{@const st = jobStatusInfo(job)}
<tr>
<td
><button
class="task-thumbnail"
aria-label={`查看任务 #${job.id}`}
onclick={() => openDetail(job)}
>
{/each}
</tbody>
</table>
{#if $jobsQuery.isLoading}<p class="mg-empty">
正在读取任务…
</p>{:else if $jobsQuery.isError}<p class="mg-empty" role="alert">
任务加载失败<Button onclick={() => $jobsQuery.refetch()}
>重试</Button
>
</p>{:else if !shown.length}<div class="mg-empty">
<p>
{search || filter !== "all"
? t("tasks.emptyFiltered")
: t("tasks.empty")}
</p>
<a class="a-btn secondary sm" href={resolve('/image')}>前往创作台</a>
</div>{/if}
</div>
</div>
<aside class="mg-detail" aria-label="任务详情">
{#if activeJob}{@const job = activeJob}{@const st = jobStatusInfo(job)}
<h2>{wfName(job)}</h2>
<div class="mg-mono">#{job.id} · {timeOf(job)}</div>
<div class="mg-row">
<span class="mg-tag {toneOf(job, st.tone)}">{labelOf(job, st)}</span
><span class="mg-dim">{jobSourceLabel(job)}</span>
</div>
<div class="mg-field">
<h3>运行状态</h3>
<p class="mg-dim">{job.error ?? st.detail}</p>
{#if job.status === "running"}<p class="mg-dim">
{progressFor(job.id)?.progress != null
? progressFor(job.id)?.progress + "%"
: "等待服务报告进度"}
</p>{/if}
</div>
{#if job.output_paths?.length}<div class="mg-field">
<h3>产出 · {job.output_paths.length}</h3>
<!-- Output paths may repeat. Keep positional video DOM reuse so playback state stays with its original slot. -->
{#each job.output_paths as path, i (i)}<div class="task-output">
{#if job.kind === "image"}<img
src={assetUrl(path)}
alt={wfName(job)}
{#if job.kind === "image" && job.output_paths?.length && !isFileMissing(job)}
<img
src={assetUrl(job.output_paths[0])}
alt=""
loading="lazy"
onerror={() => markOutputMissing(path)}
/>{:else}<video
src={assetUrl(path)}
controls
preload="metadata"><track kind="captions" /></video
>{/if}<a
class="a-btn secondary sm"
href={assetUrl(path)}
download rel="external">下载</a
>
</div>{/each}
</div>{/if}
<div class="mg-row">
{#if (st.tone === "fail" && job.error !== "cancelled") || (st.tone === "ok" && isFileMissing(job))}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => retry(job)}>重试</Button
>{/if}{#if job.status === "pending" || job.status === "running"}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => cancel(job)}>取消任务</Button
>{/if}{#if isStandaloneJob(job) && job.status !== "pending" && job.status !== "running"}<Button
size="sm"
variant="danger"
onclick={() => requestDelete(job)}>删除</Button
>{/if}
</div>
{:else}<div class="mg-empty">选择任务查看状态与产出</div>{/if}
</aside>
onerror={() => markOutputMissing(job.output_paths[0])}
/>
{:else}<Icon
name={job.kind === "image" ? "image" : "film-manage"}
size={14}
/>{/if}
</button></td
>
<td
><button
class="mg-click task-name"
title={taskName(job)}
onclick={() => openDetail(job)}>{taskName(job)}</button
></td
>
<td class="mg-muted" title={sourceOf(job)}>{sourceOf(job)}</td>
<td title={wfName(job)}>{wfName(job)}</td>
<td class="mg-muted" title={connectionOf(job)}
>{connectionOf(job)}</td
>
<td
><span
class="mg-tag {toneOf(job, st.tone)}"
title={job.error ?? st.detail}>{labelOf(job, st)}</span
>
{#if job.status === "running"}
{#if reportedProgress.has(job.id)}<div
class="task-progress"
role="progressbar"
aria-label={`任务 #${job.id} 进度`}
aria-valuenow={reportedProgress.get(job.id)}
aria-valuemin="0"
aria-valuemax="100"
title={`${reportedProgress.get(job.id)}%`}
>
<i style:width={`${reportedProgress.get(job.id)}%`}></i>
</div>
{:else}<span
class="task-progress-unknown"
title="等待服务报告进度">—</span
>{/if}
{/if}
</td>
<td class="mg-mono" title={job.created_at}>{timeOf(job)}</td>
<td
><div class="mg-row">
{#if job.status === "pending" || job.status === "running"}<Button
size="sm"
variant="ghost"
disabled={busyId === job.id}
onclick={() => cancel(job)}>取消</Button
>
{:else if (st.tone === "fail" && job.error !== "cancelled") || (st.tone === "ok" && isFileMissing(job))}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => retry(job)}>重试</Button
>
{:else}<Button
size="sm"
variant="ghost"
onclick={() => openDetail(job)}>查看</Button
>{/if}
</div></td
>
</tr>
{/each}
</tbody>
</table>
{#if $jobsQuery.isLoading}<p class="mg-empty">正在读取任务…</p>
{:else if $jobsQuery.isError}<div class="mg-empty" role="alert">
任务加载失败<Button onclick={() => $jobsQuery.refetch()}>重试</Button>
</div>
{:else if !shown.length}<div class="mg-empty">
<p>
{search || filter !== "all" || sourceFilter !== "all"
? t("tasks.emptyFiltered")
: t("tasks.empty")}
</p>
<a class="a-btn secondary sm" href={resolve("/image")}>前往创作台</a>
</div>{/if}
</div>
</ManagementPage>
<style>
.task-tabs {
width: 340px;
margin-left: 12px;
}
.task-search {
width: 240px;
}
.task-output img,
.task-output video {
width: 100%;
max-height: 300px;
object-fit: contain;
background: var(--media-bg);
}
.task-output {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>
<Modal bind:open={detailOpen} title="任务详情" size="lg">
<div class="management-page task-dialog">
{#if activeJob}{@const job = activeJob}{@const st = jobStatusInfo(job)}
<h2>{wfName(job)}</h2>
<div class="mg-mono">#{job.id} · {job.created_at}</div>
<p class="mg-muted">{taskName(job)} · {connectionOf(job)}</p>
<div class="mg-row">
<span class="mg-tag {toneOf(job, st.tone)}">{labelOf(job, st)}</span
><span class="mg-dim">{jobSourceLabel(job)}</span>
</div>
<div class="mg-field">
<h3>运行状态</h3>
<p class="mg-dim">{job.error ?? st.detail}</p>
{#if job.status === "running"}<p class="mg-dim">
{reportedProgress.has(job.id)
? reportedProgress.get(job.id) + "%"
: "等待服务报告进度"}
</p>{/if}
</div>
{#if job.output_paths?.length}<div class="mg-field">
<h3>产出 · {job.output_paths.length}</h3>
<!-- Output paths may repeat. Keep positional video DOM reuse so playback state stays with its original slot. -->
{#each job.output_paths as path, i (i)}<div class="task-output">
{#if job.kind === "image"}<img
src={assetUrl(path)}
alt={wfName(job)}
loading="lazy"
onerror={() => markOutputMissing(path)}
/>{:else}<video src={assetUrl(path)} controls preload="metadata"
><track kind="captions" /></video
>{/if}<a
class="a-btn secondary sm"
href={assetUrl(path)}
download
rel="external">下载</a
>
</div>{/each}
</div>{/if}
<div class="mg-row">
{#if (st.tone === "fail" && job.error !== "cancelled") || (st.tone === "ok" && isFileMissing(job))}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => retry(job)}>重试</Button
>{/if}{#if job.status === "pending" || job.status === "running"}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => cancel(job)}>取消任务</Button
>{/if}{#if isStandaloneJob(job) && job.status !== "pending" && job.status !== "running"}<Button
size="sm"
variant="danger"
onclick={() => {
requestDelete(job);
detailOpen = false;
}}>删除</Button
>{/if}
</div>
{:else}<div class="mg-empty">选择任务查看状态与产出</div>{/if}
{#if activeJob}<div class="mg-field">
<h3>来源筛选</h3>
<div class="mg-options" aria-label="任务来源">
{#each [["all", "全部任务"], ["standalone", "创作台"], ["project", "项目"], ["test", "工作流试跑"]] as [value, label] (value)}<button
class="mg-option"
class:on={sourceFilter === value}
aria-pressed={sourceFilter === value}
onclick={() => {
sourceFilter = value;
detailOpen = false;
}}>{label}</button
>{/each}
</div>
</div>{/if}
</div>
</Modal>
+27
View File
@@ -90,6 +90,33 @@
--manage-add-min-height: 130px;
--manage-card-min: 280px;
--manage-card-padding: 14px;
--task-table-layout: auto;
--task-thumbnail-column: 64px;
--task-progress-column: 180px;
--task-thumbnail-width: 48px;
--task-thumbnail-height: 32px;
--task-thumbnail-radius: 5px;
--task-progress-fill: linear-gradient(
90deg,
color-mix(in srgb, var(--sc-manage) 70%, var(--hi)),
var(--sc-manage)
);
--task-progress-shadow: 0 0 12px var(--sc-manage);
--task-progress-duration: 250ms;
--task-running-color: color-mix(in srgb, var(--sc-video) 80%, var(--hi));
--task-running-border: color-mix(in srgb, var(--sc-video) 40%, transparent);
--task-running-background: color-mix(
in srgb,
var(--sc-video) 10%,
transparent
);
--task-progress-height: 4px;
--task-progress-radius: 4px;
--task-progress-gap: 5px;
--task-tabs-width: 340px;
--task-tabs-offset: 12px;
--task-search-width: 240px;
--task-preview-height: 300px;
--desk-head-height: var(--head);
--desk-head-inset: 16px;
--desk-head-bg: var(--glass);
@@ -0,0 +1,44 @@
# HEL-751 · 恢复确认原件的整宽任务表格
本卡施工稿待总工独立对照审核,不代表整个 B2 样本验收通过。未合并、未部署、未迁移、未调用真实或付费生成服务。
## 基底与实现
独立核对远端及认证 API:施工基底为 `fix/hel-750-code-checks@54880e900bc2fc83cfce7d979379c5bfbbf1825e`,新分支 `feat/hel-751-task-table`,新 PR 目标仍为该检查分支。原 PR #4/#5 保持 open、未合并;未修改其正文。确认 HTML SHA256 为 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。
- `calliope-web/src/routes/tasks/+page.svelte`:移除常驻来源栏和详情栏,恢复原件八列(缩略图、任务、来源、工作流、连接、进度、时间、操作);使用原件自动列宽。页头保留五个筛选及搜索,搜索同时匹配任务标题、工作流、项目、来源、连接、错误及编号。
- 连接只读取任务的历史 endpoint 快照;未记录显示“未记录”或“等待分配”,不使用当前连接冒充历史事实。任务无标题时使用媒介和真实编号;时间列使用原件 HH:mm,详情保留完整创建时间。创作台和工作流试跑来源明确区分。
- 取消、失败/输出缺失重试直接在行内调用原 API。缩略图、任务名及“查看”打开已有 Modal,容纳真实状态、完整产物、逐项下载、手动视频播放和原撤销删除队列;来源筛选也复用该弹层,筛选后可从页头“全部来源”清除。
- 保留现有 jobStatusInfo / SSE 事件和错误语义。任务页的百分比只消费带明确 job_id 的有限数值上报;仅阶段/文本事件显示未知,不使用共享进度模块估算的百分比。未修改共享进度 store。
- `static/themes.css` 是新增任务几何/颜色/进度渐变的统一来源。旧页面局部样式移入既有 `components/manage/management.css`,继续复用表格、按钮、标签、Modal,没有另一套表格组件。共享表格仅增加布局和 box-sizing 的可选变量,其他页面默认 fixed/content-box 保持;任务页使用原件 auto/border-box。新增选择器限定任务类,未改其他页面结构。
- 两个 `scripts/verify-hel751.*` 是隔离证据工具,不是产品路由或数据。无新增依赖或 lockfile 变化;32 条既有 ESLint 局部例外及字幕边界未扩展。
## 自测与共享影响
干净 `npm ci`、`npm run lint`、`npm run check`、`npm test`、`npm run build` 均 exit 0;check 为 0 errors / 0 warnings。构建仍有既有 @xyflow 未使用导入、超过 500kB chunk 提示。Prettier 的全仓脚本仍不覆盖 Svelte 模板,本次任务模板额外显式使用原有 prettier-plugin-svelte 格式化,无扩大豁免。
后端相关回归 19 项通过:studio_generation、playground、workflow_test、stop_kills_jobs;保留既有 Pydantic warning。前端四组守护通过。
浏览器在明确标记用途的临时 SQLite 夹具上运行,所有 API 在精确 origin 下拦截,外部 origin 中止,未知写操作直接失败。原件只读取不修改。生成 2 秒含 440Hz 音轨的视频验证文件,媒体假接口支持 Range;无真实服务连接。唯一对应原件的合成图片也仅存在于 API 夹具,产品不包含示例任务、数量、时间或媒体。
四主题 × 三尺寸独立截图,共 12 组完整样本/实际/并排图及数字表。页头、表格区域、表头、行、缩略图、分段筛选、搜索框、进度条的宽高逐项与原件比较;全部通过。浏览器 254 项断言通过、0 pageerror:全部/进行/失败/图片/视频、任务标题搜索、筛选空态/真实空态/API 错误与恢复、取消/重试、原生下载、产物预览、原生播放按钮、暂停/跳播/音量、焦点圈定与返回、关闭卸载播放器、删除撤销和超时提交、来源/试跑分离、无数值进度不造百分比。
共享范围实际回归:工作流库仍是三栏(左栏 220px),连接/素材/设置路由可用,任务样式不泄漏;创作台图片和视频两路的提示词可编辑,刷新保留各自草稿。未重跑这些页面全部历史业务,不以路由冒烟代替整个 B2 验收。
复现:先通过 Multica 下载原 HTML 到仓库父目录 `attachments/`,构建前端;已有运行环境安装 Playwright、Pillow、imageio_ffmpeg。设置 `PLAYWRIGHT_MODULE` 指向已有 Playwright 模块,必要时设置浏览器库路径,然后执行 `python3 scripts/verify-hel751.py`。工具使用动态本机端口,finally 关闭浏览器/HTTP 服务,退出删除临时 SQLite/媒体;证据在仓库父目录 `hel751-evidence/`,通过任务卡附件交付。
## 仍须独立判断的可见差异
- 继承 B1 的 AIGC 品牌、导航顺序/项目范围/展开入口和队列展示未在本卡改动;它们与原件的差异仍由总工逐项审核。
- 产品不显示原件“设计演示”徽标,搜索框因此右移 90px(grade 为 90.8125px);宽高一致。
- 原件第二项演示来源为“智能体 · 分镜助手”;当前隔离夹具是具有真实项目来源契约的任务,显示“项目 · 雾港来信”。不伪造当前 API 没有的智能体来源记录。原件自动列宽随文案分配,进度列 x 差约 −4.297 至 −4.938px,列宽/行高一致,详见数字表。
- 没有真实输出的任务显示同尺寸媒介图标;视频任务尚无后端缩略图字段时也显示媒介图标,不把演示风景当成真实产物。完成的图片使用实际 output 路径;视频完整产物在弹层手动预览。真实空态、未知连接/进度如实显示。
- “克制”动效档继续按既有实现禁用,归 HEL-733;未擅自新增动效。既有任务视频的字幕欠缺未在本卡扩修;声音保留且需手动播放。未测真实 GPU/云 API、硬件音频输出和全屏。
以上是明确交付边界,不表示这些差异已经被用户接受,也不宣称整页像素完全一致。若后续要求智能体专属来源或视频缩略图,应先由总管裁定其数据契约及范围,不能把演示字符串或媒体写入产品。
## 回滚与清理
仅本卡前端和证据脚本,无数据库/配置变更,无已部署版本需要回滚。需要撤销时对本卡精确 head 执行 `git revert <HEL-751 head>` 后正常推送,保留检查基底;禁止 reset/强推。未执行回滚。
临时 SQLite、生成媒体、浏览器及动态端口 HTTP 服务在证据工具退出时清理;无临时产品路由、无调试日志写入产品。最终提交及远端 head/base、工作树状态见本卡唯一交付评论。
+565
View File
@@ -0,0 +1,565 @@
/** Run via verify-hel751.py; explicit API fake, local-only origins, no user data. */
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { pathToFileURL } from "node:url";
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE || "playwright"
);
const out = process.env.HEL751_OUT,
data = process.env.HEL751_DATA;
const fixture = JSON.parse(
await fs.readFile(path.join(data, "fixtures.json"), "utf8"),
);
let jobs = structuredClone(fixture.jobs),
apiError = false,
imageFixture;
const result = {
checks: [],
measurements: [],
errors: [],
mutations: [],
externalBlocked: [],
apiReads: [],
};
const check = (v, n) => {
result.checks.push({ name: n, pass: !!v });
if (!v) throw Error(n);
};
const server = http.createServer(async (req, res) => {
try {
const p = new URL(req.url, "http://local").pathname;
let f = path.join("calliope-web/build", decodeURIComponent(p));
if (!path.extname(f)) f = path.join("calliope-web/build", "index.html");
const bytes = await fs.readFile(f);
res.setHeader(
"Content-Type",
f.endsWith(".js")
? "text/javascript"
: f.endsWith(".css")
? "text/css"
: f.endsWith(".html")
? "text/html"
: "application/octet-stream",
);
res.end(bytes);
} catch {
res.writeHead(404);
res.end();
}
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const base = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch({
headless: true,
args: ["--no-sandbox"],
});
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
timezoneId: "Asia/Shanghai",
recordVideo: {
dir: path.join(out, "recordings"),
size: { width: 1440, height: 900 },
},
});
await context.addInitScript(() => {
localStorage.setItem("aigc-lang", "zh");
localStorage.setItem("aigc-reduce-motion", "1");
window.__sse = [];
window.EventSource = class {
constructor() {
window.__sse.push(this);
setTimeout(() => this.onopen?.(), 0);
}
addEventListener(t, f) {
(this.handlers ??= {})[t] = f;
}
close() {}
};
});
await context.route("**/*", async (route) => {
const req = route.request(),
u = new URL(req.url());
if (["data:", "blob:", "file:"].includes(u.protocol))
return route.continue();
if (u.origin !== base) {
result.externalBlocked.push(u.origin);
return route.abort();
}
if (!u.pathname.startsWith("/api/")) return route.continue();
const json = (body) => route.fulfill({ json: body });
if (u.pathname === "/api/file") {
if (u.searchParams.get("path")?.endsWith(".mp4")) {
const bytes = await fs.readFile(path.join(data, "video.mp4"));
const range = req.headers()["range"]?.match(/bytes=(\d+)-(\d*)/);
if (range) {
const start = +range[1],
end = range[2]
? Math.min(+range[2], bytes.length - 1)
: bytes.length - 1;
return route.fulfill({
status: 206,
body: bytes.subarray(start, end + 1),
contentType: "video/mp4",
headers: {
"Accept-Ranges": "bytes",
"Content-Range": `bytes ${start}-${end}/${bytes.length}`,
"Content-Length": String(end - start + 1),
},
});
}
return route.fulfill({
body: bytes,
contentType: "video/mp4",
headers: {
"Accept-Ranges": "bytes",
"Content-Length": String(bytes.length),
},
});
}
return route.fulfill({
body:
imageFixture ||
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR1kAAAAASUVORK5CYII=",
"base64",
),
contentType: "image/png",
});
}
if (req.method() !== "GET") {
result.mutations.push({ path: u.pathname, method: req.method() });
const m = u.pathname.match(/^\/api\/jobs\/(\d+)\/(cancel|retry)$/);
if (m) {
const j = jobs.find((j) => j.id === +m[1]);
j.status = m[2] === "cancel" ? "failed" : "pending";
j.error = m[2] === "cancel" ? "cancelled" : null;
return json(j);
}
const d = u.pathname.match(/^\/api\/playground\/jobs\/(\d+)$/);
if (d && req.method() === "DELETE") {
jobs = jobs.filter((j) => j.id !== +d[1]);
return json({ ok: true });
}
if (u.pathname.includes("test")) return json({ ok: true });
throw Error(`Unexpected mutation ${req.method()} ${u.pathname}`);
}
result.apiReads.push(u.pathname);
if (u.pathname === "/api/jobs") {
if (apiError)
return route.fulfill({ status: 500, body: "HEL751 isolated error" });
return json(jobs);
}
if (u.pathname === "/api/workflows") return json(fixture.workflows);
if (u.pathname === "/api/projects")
return json([{ id: 1, title: "雾港来信" }]);
if (u.pathname === "/api/settings")
return json({
comfyui_endpoints: [],
llm_profiles: [],
queue_concurrency: 1,
dry_run: false,
});
if (u.pathname === "/api/comfyui/endpoints")
return json({ endpoints: [], active_id: null });
if (u.pathname.includes("/jobs")) return json([]);
if (u.pathname.includes("/sessions")) return json([]);
if (
u.pathname === "/api/library/media" ||
u.pathname === "/api/agent/agents" ||
u.pathname === "/api/agent/memories"
)
return json([]);
const wf = u.pathname.match(/^\/api\/workflows\/(\d+)$/);
if (wf)
return json({
...fixture.workflows.find((w) => w.id === +wf[1]),
workflow_json: {},
output_map: [
{
nodeId: "2",
kind: fixture.workflows.find((w) => w.id === +wf[1]).kind,
},
],
});
return json({});
});
const page = await context.newPage();
page.on("pageerror", (e) => result.errors.push(e.message));
const ref = await context.newPage();
await ref.goto(
pathToFileURL(path.resolve("../attachments/夜片场-多主题交互样本.html"))
.href,
);
await ref.evaluate(() => {
go("tasks");
document.documentElement.classList.add("capture");
});
// Reuse the corresponding original synthetic still only inside this API fake.
// Canvas avoids capturing a parent page during its opacity entrance animation.
imageFixture = Buffer.from(
await ref.evaluate(async () => {
const img = new Image();
img.src = still(64, 96, 64);
await img.decode();
const canvas = document.createElement("canvas");
canvas.width = 48;
canvas.height = 32;
canvas.getContext("2d").drawImage(img, 0, 0, 48, 32);
return canvas.toDataURL("image/png").split(",")[1];
}),
"base64",
);
const progress = () =>
page.evaluate(() => {
for (const es of window.__sse)
for (const [id, pct] of [
[1, 62],
[2, 35],
])
es.handlers?.["job.progress"]?.({
data: JSON.stringify({
type: "job.progress",
data: { job_id: id, progress: pct, phase: "comfy_running" },
}),
});
});
const measure = (p, selectors) =>
p.evaluate(
(selectors) =>
Object.fromEntries(
Object.entries(selectors).map(([k, s]) => {
const e = document.querySelector(s);
if (!e) return [k, null];
const r = e.getBoundingClientRect(),
c = getComputedStyle(e);
return [
k,
{
x: r.x,
y: r.y,
width: r.width,
height: r.height,
font: c.fontSize,
radius: c.borderRadius,
color: c.color,
background: c.backgroundColor,
},
];
}),
),
selectors,
);
for (const [width, height] of process.env.HEL751_SKIP_VISUAL
? []
: [
[1440, 900],
[1366, 768],
[1280, 720],
])
for (const theme of ["night", "day", "grade", "bluehour"]) {
await page.setViewportSize({ width, height });
await ref.setViewportSize({ width, height });
await page.goto(base + "/tasks");
await page.locator(".task-table tbody tr").first().waitFor();
await progress();
await page.evaluate((theme) => {
localStorage.setItem("aigc-theme", theme);
window.dispatchEvent(
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
);
}, theme);
await ref.evaluate((theme) => setTheme(theme), theme);
await page.waitForTimeout(650);
const name = `tasks-${theme}-${width}`;
await page.screenshot({ path: path.join(out, name + "-actual.png") });
await ref.screenshot({ path: path.join(out, name + "-sample.png") });
result.measurements.push({
theme,
width,
height,
actual: await measure(page, {
header: ".management-header",
table: ".task-table",
head: ".task-table thead",
row: ".task-table tbody tr",
thumbnail: ".task-thumbnail",
segment: ".task-tabs",
search: ".task-search",
progress: ".task-progress",
tag: ".task-table .mg-tag",
}),
sample: await measure(ref, {
header: "#p-tasks .ph",
table: "#p-tasks .tbl",
head: "#p-tasks thead",
row: "#taskTable tr",
thumbnail: "#taskTable td>div",
segment: "#p-tasks .seg",
search: "#p-tasks .input",
progress: "#taskTable .bar",
tag: "#taskTable .tag",
}),
});
const measured = result.measurements.at(-1);
for (const key of [
"header",
"table",
"head",
"row",
"thumbnail",
"segment",
"search",
"progress",
])
for (const axis of ["width", "height"])
check(
Math.abs(measured.actual[key][axis] - measured.sample[key][axis]) <
0.1,
`${name}: ${key} ${axis} matches sample`,
);
check(
(await page.locator(".mg-side,.mg-detail").count()) === 0,
`${name}: no permanent sidebars`,
);
check(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
`${name}: no document overflow`,
);
}
await page.setViewportSize({ width: 1440, height: 900 });
if (process.env.HEL751_SKIP_VISUAL) {
await page.goto(base + "/tasks");
await page.locator("tbody tr").first().waitFor();
}
await page.locator(".task-tabs button").nth(1).click();
check((await page.locator("tbody tr").count()) === 3, "active filter");
await page.locator(".task-tabs button").nth(2).click();
check((await page.locator("tbody tr").count()) === 1, "failed filter");
await page.locator(".task-tabs button").nth(3).click();
check((await page.locator("tbody tr").count()) === 3, "image filter");
await page.locator(".task-tabs button").nth(4).click();
check((await page.locator("tbody tr").count()) === 3, "video filter");
await page.locator(".task-tabs button").first().click();
await page.getByRole("searchbox").fill("镜头 07");
await page.waitForTimeout(100);
check((await page.locator("tbody tr").count()) === 1, "search by task title");
await page.getByRole("searchbox").fill("HEL751 absent");
await page.waitForTimeout(100);
check((await page.locator("tbody tr").count()) === 0, "search empty");
await page.screenshot({ path: path.join(out, "search-empty.png") });
await page.getByRole("searchbox").fill("");
await page.getByRole("button", { name: "查看任务 #6", exact: true }).click();
await page.getByRole("dialog").waitFor();
const video = page.locator("video");
await video.evaluate(
(v) =>
new Promise((r) =>
v.readyState >= 1
? r()
: v.addEventListener("loadedmetadata", r, { once: true }),
),
);
check(
await video.evaluate(
(v) =>
v.paused && v.controls && !v.autoplay && !v.muted && v.volume === 1,
),
"video manual playback with sound",
);
await video.hover();
const videoBox = await video.boundingBox();
await video.click({ position: { x: 26, y: videoBox.height - 28 } });
await page.waitForTimeout(250);
check(
await video.evaluate((v) => !v.paused && v.currentTime > 0),
"explicit video play",
);
await video.evaluate((v) => {
v.pause();
v.currentTime = 0.7;
v.volume = 0.4;
});
await page.waitForTimeout(300);
result.videoState = await video.evaluate((v) => ({
paused: v.paused,
time: v.currentTime,
volume: v.volume,
duration: v.duration,
seekable: v.seekable.length,
error: v.error?.message,
}));
check(
result.videoState.paused &&
result.videoState.time > 0.6 &&
Math.abs(result.videoState.volume - 0.4) < 0.001,
"pause seek volume",
);
await page.screenshot({ path: path.join(out, "video-preview.png") });
await page.keyboard.press("Tab");
check(
await page
.getByRole("dialog")
.evaluate((e) => e.contains(document.activeElement)),
"modal focus stays inside",
);
await page.getByRole("dialog").locator(".modal-close").focus();
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
check((await page.locator("video").count()) === 0, "close unmounts video");
check(
await page
.getByRole("button", { name: "查看任务 #6", exact: true })
.evaluate((e) => e === document.activeElement),
"modal restores trigger focus",
);
await page.getByRole("button", { name: "查看任务 #5", exact: true }).click();
await page.getByRole("dialog").waitFor();
check(
(await page.getByRole("dialog").locator("img").count()) === 1,
"image preview",
);
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("link", { name: "下载", exact: true }).click(),
]);
check(!(await download.failure()), "native image download");
await download.saveAs(path.join(out, "downloaded-fixture.png"));
await page.getByRole("button", { name: "删除", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 5,
"delete removes row immediately",
);
await page.getByRole("button", { name: "撤销", exact: true }).click();
check((await page.locator("tbody tr").count()) === 6, "undo restores row");
check(
!result.mutations.some((m) => m.method === "DELETE"),
"undo prevents DELETE",
);
await page.getByRole("button", { name: "查看任务 #5", exact: true }).click();
await page.getByRole("button", { name: "删除", exact: true }).click();
await page.waitForTimeout(5300);
check(
result.mutations.some((m) => m.method === "DELETE"),
"delete commits after undo window",
);
await page
.locator("tbody tr")
.filter({ hasText: "参考生视频 #21" })
.getByRole("button", { name: "重试", exact: true })
.click();
await page.waitForTimeout(150);
check(
jobs.find((j) => j.id === 4).status === "pending",
"retry uses real API contract",
);
await page
.locator("tbody tr")
.filter({ hasText: "Qwen 局部重绘" })
.getByRole("button", { name: "取消", exact: true })
.click();
await page.waitForTimeout(150);
check(
jobs.find((j) => j.id === 3).error === "cancelled",
"cancel uses real API contract",
);
await page.getByRole("button", { name: "查看任务 #1", exact: true }).click();
await page.getByRole("button", { name: "创作台", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 2,
"source filter retained in details",
);
await page.getByRole("button", { name: "全部来源", exact: true }).click();
const testJob = {
...structuredClone(fixture.jobs[4]),
id: 8,
origin: "workflow_test",
payload: { source: "workflow_test", title: "HEL751 workflow test" },
output_paths: ["HEL751-fixture-4.png"],
};
jobs.push(testJob);
await page.goto(base + "/tasks");
await page.getByRole("button", { name: "查看任务 #8", exact: true }).click();
await page.getByRole("button", { name: "工作流试跑", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 1,
"workflow test source isolated",
);
await page.getByRole("button", { name: "全部来源", exact: true }).click();
// Pending/running without a numeric report must remain unknown.
await page.goto(base + "/tasks");
await page.locator("tbody tr").first().waitFor();
check(
(await page.locator(".task-progress").count()) === 0,
"no fabricated percentages after reload",
);
jobs = [];
await page.goto(base + "/tasks");
await page.waitForTimeout(400);
check((await page.locator("tbody tr").count()) === 0, "true empty data");
await page.screenshot({ path: path.join(out, "true-empty.png") });
apiError = true;
await page.goto(base + "/tasks");
await page
.getByRole("alert")
.filter({ hasText: "任务加载失败" })
.waitFor({ timeout: 15000 });
check(true, "API failure shown honestly");
apiError = false;
jobs = structuredClone(fixture.jobs);
await page.getByRole("button", { name: "重试", exact: true }).click();
await page.locator("tbody tr").first().waitFor();
check(true, "load retry restores tasks");
for (const route of [
"workflows",
"connections",
"library",
"settings",
"create?kind=image",
"create?kind=video",
]) {
await page.goto(base + "/" + route);
await page.waitForTimeout(400);
await page.screenshot({
path: path.join(out, "regression-" + route.replace(/\W/g, "-") + ".png"),
});
check(
(await page.locator(".task-table").count()) === 0,
`${route}: task table styles scoped`,
);
if (route.startsWith("create?")) {
const textarea = page.locator(".studio-form textarea").first();
await textarea.fill("HEL751 isolated creative draft");
check(
(await textarea.inputValue()) === "HEL751 isolated creative draft",
`${route}: prompt remains editable`,
);
await page.reload();
await page.locator(".studio-form textarea").first().waitFor();
check(
(await page.locator(".studio-form textarea").first().inputValue()) ===
"HEL751 isolated creative draft",
`${route}: draft survives reload`,
);
}
if (route === "workflows")
check(
(await page
.locator(".mg-side")
.evaluate((e) => e.getBoundingClientRect().width)) === 220,
"workflow sidebar retains 220px",
);
}
check(result.errors.length === 0, "no browser page errors");
await context.close();
} finally {
await fs.writeFile(
path.join(out, "results.json"),
JSON.stringify(result, null, 2),
);
await browser.close();
await new Promise((r) => server.close(r));
}
+119
View File
@@ -0,0 +1,119 @@
"""Foreground isolated evidence run. No user data or external generation services."""
import json
import os
from pathlib import Path
import sqlite3
import subprocess
import tempfile
import imageio_ffmpeg
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT.parent / "hel751-evidence"
OUT.mkdir(exist_ok=True)
# This database exists only for the duration of this foreground verification.
with tempfile.TemporaryDirectory(prefix="hel751-isolated-", dir=OUT) as data:
db = sqlite3.connect(Path(data) / "fixture.sqlite")
db.execute("create table fixture_jobs (id integer primary key, purpose text, body text)")
names = [
"镜头 07 · 首尾帧", "分镜草图 8/12", "Qwen 局部重绘 #62",
"参考生视频 #21", "Krea2 文生图 #55", "镜头 06 · 首尾帧",
]
workflow_names = [
"H3 首尾帧生视频", "Krea2 · 4 宫格故事板", "Qwen 2.1 · 局部重绘",
"H3 参考生视频", "Krea2 · 文生图", "H3 首尾帧生视频",
]
workflow_rows = []
for i in range(6):
kind = "video" if i in (0, 3, 5) else "image"
job = {
"id": i + 1,
"project_id": 1 if i in (0, 1, 5) else 0,
"scene_id": 1 if i in (0, 1, 5) else None,
"clip_id": None,
"kind": kind,
"workflow_id": i + 1,
"status": ["running", "running", "pending", "failed", "done", "done"][i],
"payload": {
"title": names[i],
"source": "workbench" if i in (0, 1, 5) else "studio-" + kind,
"endpoint": {"id": "hel751-fake", "name": "5090D"},
},
"output_paths": (
["HEL751-fixture-4.png" if i == 4 else "HEL751-fixture-video.mp4"]
if i > 3 else []
),
"error": "OOM" if i == 3 else None,
"created_at": f"2026-10-21T13:0{i}:00Z",
"started_at": None,
"completed_at": None,
"retry_count": 0,
"phase": "comfy_running" if i < 2 else None,
}
db.execute(
"insert into fixture_jobs values (?,?,?)",
(i + 1, "HEL-751 isolated sample correspondence; never production",
json.dumps(job, ensure_ascii=False)),
)
workflow_rows.append({
"id": i + 1, "name": workflow_names[i], "kind": kind,
"media_io": {"in": [], "out": [kind]}, "tags": [],
"favorite": False, "category": None, "model_family": None,
"usage": "text2video" if kind == "video" else "text2image",
"is_enabled": True, "needs_mapping": False,
"input_schema": [{
"nodeId": "1", "label": "HEL751 fixture prompt",
"role": "prompt", "kind": "textarea", "defaultValue": "", "required": True,
}],
"output_schema": [{"nodeId": "2", "label": "Output", "kind": kind}],
"endpoint_id": "hel751-fake",
})
db.commit()
jobs = [json.loads(row[0]) for row in db.execute("select body from fixture_jobs order by id")]
db.close()
(Path(data) / "fixtures.json").write_text(
json.dumps({"jobs": jobs, "workflows": workflow_rows}, ensure_ascii=False),
encoding="utf-8",
)
subprocess.run(
[
imageio_ffmpeg.get_ffmpeg_exe(), "-y", "-f", "lavfi", "-i",
"color=c=navy:s=320x180:r=24", "-f", "lavfi", "-i",
"sine=frequency=440:sample_rate=44100", "-t", "2", "-c:v",
"libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(Path(data) / "video.mp4"),
],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
)
env = os.environ | {"HEL751_OUT": str(OUT), "HEL751_DATA": data}
subprocess.run(["node", "scripts/verify-hel751.mjs"], cwd=ROOT, env=env, check=True)
print("Isolated database and media removed; all run-owned servers closed.")
# Compose unchanged captures into review artifacts; never alter the original HTML.
if not os.environ.get("HEL751_SKIP_VISUAL"):
evidence = json.loads((OUT / "results.json").read_text())
lines = [
"| Theme / viewport | Element | Sample x,y,w,h | Actual x,y,w,h | Delta x,y,w,h |",
"|---|---|---|---|---|",
]
for measurement in evidence["measurements"]:
name = f"tasks-{measurement['theme']}-{measurement['width']}"
sample = Image.open(OUT / f"{name}-sample.png").convert("RGB")
actual = Image.open(OUT / f"{name}-actual.png").convert("RGB")
pair = Image.new("RGB", (sample.width * 2, sample.height + 28), "#151515")
ImageDraw.Draw(pair).text((12, 8), "Confirmed sample (left) / Isolated implementation (right)", fill="white")
pair.paste(sample, (0, 28))
pair.paste(actual, (sample.width, 28))
pair.save(OUT / f"{name}-pair.png")
for key, expected in measurement["sample"].items():
observed = measurement["actual"][key]
coordinates = ("x", "y", "width", "height")
values = lambda row: ", ".join(f"{row[c]:.3f}" for c in coordinates)
delta = {c: observed[c] - expected[c] for c in coordinates}
lines.append(
f"| {measurement['theme']} {measurement['width']}×{measurement['height']} "
f"| {key} | {values(expected)} | {values(observed)} | {values(delta)} |"
)
(OUT / "dimensions.md").write_text("\n".join(lines) + "\n", encoding="utf-8")