Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2004f527cc | ||
|
|
dca88b3765 |
@@ -28,6 +28,10 @@
|
||||
>
|
||||
<b>{title}</b>{@render tools?.()}<span class="mg-grow"
|
||||
></span>{@render actions?.()}
|
||||
<!-- The approved header reserves this inline width independently of demo copy. -->
|
||||
<span class="mg-header-reserve" aria-hidden="true"
|
||||
><span></span><span>    </span></span
|
||||
>
|
||||
</header>
|
||||
{/snippet}
|
||||
<section
|
||||
|
||||
@@ -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,101 @@ 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);
|
||||
}
|
||||
|
||||
.mg-header-reserve {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 var(--manage-header-reserve-padding);
|
||||
border: 1px solid transparent;
|
||||
font: var(--manage-header-reserve-font);
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
.mg-header-reserve > :first-child {
|
||||
width: var(--manage-header-reserve-dot);
|
||||
}
|
||||
.mg-header-reserve > :last-child {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { onMount } from "svelte";
|
||||
import { spotlight } from "$lib/actions/spotlight";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { toStore } from "svelte/store";
|
||||
import { page } from "$app/state";
|
||||
@@ -184,48 +183,61 @@
|
||||
class="refract-shell"
|
||||
class:focus
|
||||
class:expanded
|
||||
style:--scene={`var(--sc-${scene})`}
|
||||
style:--scene={scene === "gold" ? "var(--gold)" : `var(--sc-${scene})`}
|
||||
>
|
||||
<aside class="sidebar" aria-label="全站导航">
|
||||
<a class="brand" href={resolve('/')} aria-label="返回工作室"
|
||||
>AIGC{#if expanded}<small>创作工作台</small>{/if}</a
|
||||
<a class="brand" href={resolve("/")} aria-label="返回工作室"
|
||||
><b>片</b>{#if expanded}<small>创作工作台</small>{/if}</a
|
||||
>
|
||||
<nav aria-label="主要导航">
|
||||
{#each mainNav as item (item.id)}<a
|
||||
class="nav-item reference-option"
|
||||
use:spotlight
|
||||
class="nav-item"
|
||||
href={resolve(item.href)}
|
||||
style:--nav-c={`var(--sc-${item.scene})`}
|
||||
style:--nav-c={item.scene === "gold"
|
||||
? "var(--gold)"
|
||||
: `var(--sc-${item.scene})`}
|
||||
aria-current={item.matches(page.url.pathname) ? "page" : undefined}
|
||||
title={item.label}
|
||||
><Icon name={item.icon} size={20} /><span>{item.label}</span></a
|
||||
title={item.id === "connections"
|
||||
? `${item.label} · ${connDetail}`
|
||||
: item.label}
|
||||
><Icon name={item.icon} size={18} /><span>{item.label}</span></a
|
||||
>{/each}
|
||||
</nav>
|
||||
<hr />
|
||||
<nav class="management" aria-label="管理导航">
|
||||
{#each NAV_MANAGE as item (item.id)}<a
|
||||
class="nav-item reference-option"
|
||||
use:spotlight
|
||||
class="nav-item"
|
||||
href={resolve(item.href)}
|
||||
style:--nav-c={`var(--sc-${item.scene})`}
|
||||
style:--nav-c={item.scene === "gold"
|
||||
? "var(--gold)"
|
||||
: `var(--sc-${item.scene})`}
|
||||
aria-current={item.matches(page.url.pathname) ? "page" : undefined}
|
||||
title={item.label}
|
||||
title={item.id === "connections"
|
||||
? `${item.label} · ${connDetail}`
|
||||
: item.label}
|
||||
><Icon name={item.icon} size={18} /><span>{item.label}</span></a
|
||||
>{/each}
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
<button
|
||||
class="nav-item reference-option"
|
||||
use:spotlight
|
||||
onclick={toggleNavigation}
|
||||
aria-expanded={expanded}
|
||||
title={expanded ? "收起导航" : "展开导航"}
|
||||
><Icon name="chevron-right" size={18} /><span
|
||||
>{expanded ? "收起导航" : "展开导航"}</span
|
||||
></button
|
||||
<a
|
||||
class="queue-dot"
|
||||
href={resolve("/tasks")}
|
||||
title={`${genJobs.filter((j) => j.status === "running" || j.status === "pending").length} 个任务进行中`}
|
||||
aria-label="查看生成任务"
|
||||
>
|
||||
<Icon name="nav-queue" />
|
||||
{#if genJobs.some((j) => j.status === "running") && !reduceMotion.manual && !reduceMotion.system}<span
|
||||
class="queue-ring"
|
||||
aria-hidden="true"
|
||||
></span>{/if}
|
||||
{#if genJobs.some((j) => j.status === "running" || j.status === "pending")}<i
|
||||
>{genJobs.filter(
|
||||
(j) => j.status === "running" || j.status === "pending",
|
||||
).length}</i
|
||||
>{/if}
|
||||
</a>
|
||||
<button
|
||||
class="nav-item reference-option"
|
||||
use:spotlight
|
||||
class="nav-item"
|
||||
onclick={() => (themeOpen = true)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={themeOpen}
|
||||
@@ -234,27 +246,36 @@
|
||||
></button
|
||||
>
|
||||
<a
|
||||
class="nav-item reference-option"
|
||||
use:spotlight
|
||||
href={resolve('/settings')}
|
||||
class="nav-item"
|
||||
href={resolve("/settings")}
|
||||
aria-current={page.url.pathname === "/settings" ? "page" : undefined}
|
||||
title="设置"><Icon name="settings" size={18} /><span>设置</span></a
|
||||
title="设置"><Icon name="nav-gear" size={18} /><span>设置</span></a
|
||||
>
|
||||
<a
|
||||
class="connection"
|
||||
href={resolve('/connections')}
|
||||
title={connDetail}
|
||||
aria-label={connDetail}
|
||||
><span class="status-chip-dot {connDot}"></span>{#if expanded}<span
|
||||
>{probeState === "ok"
|
||||
? "本地服务已连接"
|
||||
: probeState === "testing"
|
||||
? "正在检测服务"
|
||||
: probeState === "fail"
|
||||
? "本地服务离线"
|
||||
: "配置本地服务"}</span
|
||||
>{/if}</a
|
||||
<button
|
||||
class="nav-item"
|
||||
onclick={toggleNavigation}
|
||||
aria-expanded={expanded}
|
||||
title={expanded ? "收起导航" : "展开导航"}
|
||||
><Icon name="nav-palette" size={18} /><span
|
||||
>{expanded ? "收起" : "导航"}</span
|
||||
></button
|
||||
>
|
||||
{#if expanded}<a
|
||||
class="connection"
|
||||
href={resolve("/connections")}
|
||||
title={connDetail}
|
||||
aria-label={connDetail}
|
||||
><span class="status-chip-dot {connDot}"></span>{#if expanded}<span
|
||||
>{probeState === "ok"
|
||||
? "本地服务已连接"
|
||||
: probeState === "testing"
|
||||
? "正在检测服务"
|
||||
: probeState === "fail"
|
||||
? "本地服务离线"
|
||||
: "配置本地服务"}</span
|
||||
>{/if}</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
<div
|
||||
@@ -272,14 +293,14 @@
|
||||
{#if currentNav?.id === "connections"}
|
||||
<a
|
||||
class="a-btn secondary sm"
|
||||
href={resolve('/connections')}
|
||||
href={resolve("/connections")}
|
||||
aria-current={page.url.pathname === "/connections"
|
||||
? "page"
|
||||
: undefined}>ComfyUI 连接</a
|
||||
>
|
||||
<a
|
||||
class="a-btn secondary sm"
|
||||
href={resolve('/models')}
|
||||
href={resolve("/models")}
|
||||
aria-current={page.url.pathname === "/models" ? "page" : undefined}
|
||||
>大语言模型</a
|
||||
>
|
||||
@@ -325,11 +346,6 @@
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.management {
|
||||
border-top: 1px solid var(--line-2);
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.theme-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -353,7 +369,7 @@
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.nav-item[aria-current="page"] :global(svg) {
|
||||
color: var(--nav-c, var(--scene));
|
||||
color: var(--nav-c, var(--gold));
|
||||
}
|
||||
|
||||
.refract-shell {
|
||||
@@ -366,28 +382,45 @@
|
||||
min-width: 0;
|
||||
}
|
||||
.refract-shell.expanded {
|
||||
grid-template-columns: 174px minmax(0, 1fr);
|
||||
grid-template-columns: var(--rail-expanded-width) minmax(0, 1fr);
|
||||
}
|
||||
.sidebar {
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 16px 8px 9px;
|
||||
--icon-stroke: var(--manage-icon-stroke);
|
||||
padding: var(--rail-padding);
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
overflow: visible;
|
||||
}
|
||||
.brand {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
color: var(--gold);
|
||||
text-align: center;
|
||||
width: var(--rail-brand-size);
|
||||
height: var(--rail-brand-size);
|
||||
border-radius: var(--rail-brand-radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: var(--rail-brand-margin);
|
||||
position: relative;
|
||||
background: var(--brand-grad);
|
||||
box-shadow: var(--brand-glow);
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
display: block;
|
||||
margin: 2px 0 18px;
|
||||
text-shadow: var(--brand-glow);
|
||||
flex: none;
|
||||
}
|
||||
.brand b {
|
||||
font: 800 15px/1 var(--font);
|
||||
color: var(--brand-ink);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.sidebar hr {
|
||||
width: 28px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line-2);
|
||||
margin: var(--rail-divider-margin);
|
||||
flex: none;
|
||||
}
|
||||
.brand small {
|
||||
display: block;
|
||||
@@ -403,26 +436,24 @@
|
||||
gap: 2px;
|
||||
}
|
||||
.nav-item {
|
||||
min-height: 44px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: var(--rail-nav-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 7px 3px;
|
||||
gap: 3px;
|
||||
font: inherit;
|
||||
padding: var(--rail-nav-padding);
|
||||
color: var(--tx-3);
|
||||
text-decoration: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: var(--r-md);
|
||||
border-radius: var(--rail-nav-radius);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
color 180ms ease,
|
||||
transform 160ms var(--ease-out);
|
||||
color var(--t-base),
|
||||
background var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-item[aria-current="page"] {
|
||||
@@ -436,20 +467,30 @@
|
||||
.nav-item[aria-current="page"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
left: -6px;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--nav-c, var(--scene));
|
||||
background: var(--nav-c, var(--gold));
|
||||
box-shadow: var(
|
||||
--rail-active-shadow,
|
||||
0 0 var(--rail-active-shadow-blur) var(--nav-c, var(--gold))
|
||||
);
|
||||
}
|
||||
.nav-item:active {
|
||||
transform: scale(var(--press-scale));
|
||||
}
|
||||
.nav-item > span {
|
||||
font-size: var(--rail-nav-size);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.expanded .sidebar {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.expanded .nav-item {
|
||||
width: var(--rail-expanded-nav-width);
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
@@ -457,26 +498,56 @@
|
||||
padding: 10px 14px;
|
||||
min-height: 43px;
|
||||
}
|
||||
.expanded .nav-item > span {
|
||||
font-size: var(--rail-expanded-label-size);
|
||||
}
|
||||
.expanded .nav-item::before {
|
||||
display: none;
|
||||
}
|
||||
.sidebar-bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.sidebar-bottom .nav-item {
|
||||
min-height: 45px;
|
||||
font-size: 9px;
|
||||
padding: 7px 3px;
|
||||
gap: 4px;
|
||||
.queue-dot {
|
||||
box-sizing: border-box;
|
||||
width: var(--rail-queue-size);
|
||||
height: var(--rail-queue-size);
|
||||
border-radius: var(--rail-brand-radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
color: var(--tx-2);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.expanded .sidebar-bottom .nav-item {
|
||||
min-height: 39px;
|
||||
padding: 9px 14px;
|
||||
font-size: 12px;
|
||||
.queue-ring {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: var(--rail-queue-ring-radius);
|
||||
border: var(--rail-queue-ring-border) solid transparent;
|
||||
border-top-color: var(--sc-video);
|
||||
animation: rail-spin var(--rail-queue-ring-duration) linear infinite;
|
||||
}
|
||||
@keyframes rail-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.queue-dot i {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--sc-video);
|
||||
color: var(--on-scene);
|
||||
font: 700 10px/16px var(--mono);
|
||||
font-style: normal;
|
||||
}
|
||||
.connection {
|
||||
display: flex;
|
||||
@@ -587,48 +658,4 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
@media (max-height: 780px) {
|
||||
.sidebar {
|
||||
padding-top: 11px;
|
||||
}
|
||||
.brand {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.nav-item {
|
||||
min-height: 40px;
|
||||
padding: 5px 0 3px;
|
||||
gap: 3px;
|
||||
}
|
||||
.sidebar-bottom {
|
||||
gap: 0;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.sidebar-bottom .nav-item {
|
||||
min-height: 37px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 740px) {
|
||||
.sidebar {
|
||||
padding-top: 10px;
|
||||
}
|
||||
.brand {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sidebar nav {
|
||||
gap: 4px;
|
||||
}
|
||||
.nav-item {
|
||||
min-height: 38px;
|
||||
padding: 4px 2px;
|
||||
gap: 3px;
|
||||
}
|
||||
.sidebar-bottom {
|
||||
padding-top: 8px;
|
||||
}
|
||||
.sidebar-bottom .nav-item {
|
||||
min-height: 31px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -80,16 +80,19 @@
|
||||
}
|
||||
.choices.tiles {
|
||||
--choice-check-width: 0px;
|
||||
--choice-display: flex;
|
||||
--choice-box-sizing: border-box;
|
||||
--choice-gap: 6px;
|
||||
--choice-copy-gap: 6px;
|
||||
--choice-tone-align: flex-start;
|
||||
--choice-tone-align: stretch;
|
||||
--choice-preview-art: var(--theme-preview-art);
|
||||
--choice-dock-width: 100%;
|
||||
--choice-dock-background: var(--bg-3);
|
||||
--choice-radius: 12px;
|
||||
--choice-name-size: 12.5px;
|
||||
--choice-tone-font: 11px var(--font-body);
|
||||
--choice-name-size: var(--manage-theme-name-size);
|
||||
--choice-name-line: var(--manage-theme-line);
|
||||
--choice-tone-font: var(--manage-theme-tone-size)/var(--manage-theme-line)
|
||||
var(--font);
|
||||
--choice-selected: color-mix(in srgb, var(--gold) 60%, transparent);
|
||||
--choice-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 14%, transparent);
|
||||
}
|
||||
@@ -104,7 +107,8 @@
|
||||
}
|
||||
.choice {
|
||||
box-sizing: var(--choice-box-sizing, content-box);
|
||||
display: grid;
|
||||
display: var(--choice-display, grid);
|
||||
flex-direction: column;
|
||||
grid-template-columns: minmax(0, 1fr) var(--choice-check-width, 18px);
|
||||
gap: var(--choice-gap, 8px);
|
||||
padding: 8px;
|
||||
@@ -209,6 +213,7 @@
|
||||
}
|
||||
.copy strong {
|
||||
font-size: var(--choice-name-size, 13px);
|
||||
line-height: var(--choice-name-line, inherit);
|
||||
}
|
||||
.copy small {
|
||||
font: var(--choice-tone-font, 10px var(--mono));
|
||||
|
||||
@@ -20,22 +20,22 @@ export interface NavItem {
|
||||
label: string;
|
||||
icon: IconName;
|
||||
href: Pathname;
|
||||
scene: "project" | "image" | "agent" | "manage" | "asset";
|
||||
scene: "project" | "image" | "agent" | "manage" | "asset" | "gold";
|
||||
matches: (path: string) => boolean;
|
||||
}
|
||||
export const NAV_MAIN: NavItem[] = [
|
||||
{
|
||||
id: "projects",
|
||||
label: "工作室",
|
||||
icon: "home",
|
||||
icon: "nav-home",
|
||||
href: "/",
|
||||
scene: "project",
|
||||
scene: "gold",
|
||||
matches: (p) => p === "/",
|
||||
},
|
||||
{
|
||||
id: "workbench",
|
||||
label: "项目",
|
||||
icon: "folder",
|
||||
icon: "nav-project",
|
||||
href: "/workbench",
|
||||
scene: "project",
|
||||
matches: (p) => /^\/(workbench|project)(\/|$)/.test(p),
|
||||
@@ -43,7 +43,7 @@ export const NAV_MAIN: NavItem[] = [
|
||||
{
|
||||
id: "image",
|
||||
label: "创作台",
|
||||
icon: "image",
|
||||
icon: "nav-create",
|
||||
href: "/create",
|
||||
scene: "image",
|
||||
matches: (p) =>
|
||||
@@ -54,7 +54,7 @@ export const NAV_MAIN: NavItem[] = [
|
||||
{
|
||||
id: "agents",
|
||||
label: "智能体",
|
||||
icon: "sparkle",
|
||||
icon: "nav-agent",
|
||||
href: "/agents",
|
||||
scene: "agent",
|
||||
matches: (p) => p === "/agents",
|
||||
@@ -62,17 +62,17 @@ export const NAV_MAIN: NavItem[] = [
|
||||
{
|
||||
id: "canvas",
|
||||
label: "画布",
|
||||
icon: "story",
|
||||
icon: "nav-canvas",
|
||||
href: "/canvas",
|
||||
scene: "manage",
|
||||
scene: "image",
|
||||
matches: (p) => p.startsWith("/canvas"),
|
||||
},
|
||||
{
|
||||
id: "library",
|
||||
label: "素材库",
|
||||
icon: "assets",
|
||||
icon: "nav-lib",
|
||||
href: "/library",
|
||||
scene: "asset",
|
||||
scene: "gold",
|
||||
matches: (p) => p === "/library",
|
||||
},
|
||||
];
|
||||
@@ -80,7 +80,7 @@ export const NAV_MANAGE: NavItem[] = [
|
||||
{
|
||||
id: "tasks",
|
||||
label: "任务",
|
||||
icon: "clock",
|
||||
icon: "nav-queue",
|
||||
href: "/tasks",
|
||||
scene: "manage",
|
||||
matches: (p) => p === "/tasks",
|
||||
@@ -88,7 +88,7 @@ export const NAV_MANAGE: NavItem[] = [
|
||||
{
|
||||
id: "workflows",
|
||||
label: "工作流",
|
||||
icon: "playground",
|
||||
icon: "nav-flow",
|
||||
href: "/workflows",
|
||||
scene: "manage",
|
||||
matches: (p) => p === "/workflows",
|
||||
@@ -96,7 +96,7 @@ export const NAV_MANAGE: NavItem[] = [
|
||||
{
|
||||
id: "connections",
|
||||
label: "模型连接",
|
||||
icon: "link",
|
||||
icon: "nav-plug",
|
||||
href: "/model-connections",
|
||||
scene: "manage",
|
||||
matches: (p) =>
|
||||
@@ -107,7 +107,7 @@ export const NAV_FOOT: NavItem[] = [
|
||||
{
|
||||
id: "settings",
|
||||
label: "设置",
|
||||
icon: "settings",
|
||||
icon: "nav-gear",
|
||||
href: "/settings",
|
||||
scene: "manage",
|
||||
matches: (p) => p === "/settings",
|
||||
|
||||
@@ -10,6 +10,28 @@ export const icons = {
|
||||
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 4v16M17 4v16M2 9h5M2 15h5M17 9h5M17 15h5"/>',
|
||||
"arrow-right": '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
||||
pin: '<path d="M12 17v5M5 17h14l-2-4V5a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v8z"/>',
|
||||
// Approved HEL-726 rail geometry; existing tool icons retain their contracts.
|
||||
"nav-home":
|
||||
'<path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/>',
|
||||
"nav-project":
|
||||
'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M8 4v5M13 4v5"/>',
|
||||
"nav-create":
|
||||
'<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/><circle cx="12" cy="12" r="3"/>',
|
||||
"nav-agent":
|
||||
'<path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/><path d="M18.5 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z"/>',
|
||||
"nav-canvas":
|
||||
'<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/>',
|
||||
"nav-lib":
|
||||
'<rect x="3" y="5" width="18" height="15" rx="2"/><circle cx="9" cy="10" r="2"/><path d="m21 16-5-5-9 9"/>',
|
||||
"nav-queue":
|
||||
'<path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="16" r="3"/>',
|
||||
"nav-flow":
|
||||
'<rect x="3" y="3" width="6" height="6" rx="1.5"/><rect x="15" y="15" width="6" height="6" rx="1.5"/><path d="M9 6h4a2 2 0 0 1 2 2v7"/>',
|
||||
"nav-plug": '<path d="M9 2v5M15 2v5M6 7h12v4a6 6 0 0 1-12 0zM12 17v5"/>',
|
||||
"nav-gear":
|
||||
'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
|
||||
"nav-palette":
|
||||
'<path d="M12 3a9 9 0 1 0 0 18c1 0 1.5-.8 1.2-1.6-.4-1 .3-2.4 1.6-2.4H17a4 4 0 0 0 4-4c0-5.5-4-10-9-10z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7.5" r="1"/>',
|
||||
// Navigation
|
||||
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M9 22V12h6v10"/>',
|
||||
story:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -90,6 +90,58 @@
|
||||
--manage-add-min-height: 130px;
|
||||
--manage-card-min: 280px;
|
||||
--manage-card-padding: 14px;
|
||||
/* Shared sample rail and management typography, not per-theme offsets. */
|
||||
--rail-padding: 12px 0 10px;
|
||||
--rail-brand-size: 40px;
|
||||
--rail-brand-margin: 10px;
|
||||
--rail-brand-radius: 12px;
|
||||
--rail-nav-width: 56px;
|
||||
--rail-expanded-width: 174px;
|
||||
--rail-expanded-nav-width: 158px;
|
||||
--rail-expanded-label-size: 12px;
|
||||
--rail-nav-padding: 7px 0 6px;
|
||||
--rail-nav-size: 11px;
|
||||
--rail-nav-radius: 12px;
|
||||
--rail-divider-margin: 8px 0;
|
||||
--rail-queue-size: 40px;
|
||||
--rail-queue-ring-radius: 12px;
|
||||
--rail-queue-ring-border: 1.5px;
|
||||
--rail-queue-ring-duration: 1.6s;
|
||||
--rail-active-shadow: initial;
|
||||
--rail-active-shadow-blur: 12px;
|
||||
--manage-header-reserve-font: 11px/1.55 var(--font);
|
||||
--manage-header-reserve-padding: 10px;
|
||||
--manage-header-reserve-dot: 6px;
|
||||
--manage-theme-name-size: 12.5px;
|
||||
--manage-theme-tone-size: 11px;
|
||||
--manage-theme-line: 1.55;
|
||||
--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);
|
||||
@@ -225,6 +277,7 @@
|
||||
}
|
||||
|
||||
[data-theme="day"] {
|
||||
--rail-active-shadow: none;
|
||||
color-scheme: light;
|
||||
--bg-0: #e9e4da;
|
||||
--bg-1: #f4f1ea;
|
||||
@@ -307,6 +360,9 @@
|
||||
}
|
||||
|
||||
[data-theme="grade"] {
|
||||
--rail-active-shadow: none;
|
||||
--rail-nav-radius: 4px;
|
||||
--rail-brand-radius: 4px;
|
||||
--desk-filter-radius: 3px;
|
||||
--desk-connection-radius: 3px;
|
||||
color-scheme: dark;
|
||||
@@ -404,6 +460,7 @@
|
||||
}
|
||||
|
||||
[data-theme="bluehour"] {
|
||||
--rail-nav-radius: 14px;
|
||||
--desk-head-bg: color-mix(in srgb, #0f1838 55%, transparent);
|
||||
color-scheme: dark;
|
||||
--bg-0: #0a1029;
|
||||
|
||||
@@ -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、工作树状态见本卡唯一交付评论。
|
||||
@@ -0,0 +1,45 @@
|
||||
# HEL-767 · 搜索位置、主题卡片和侧栏样本返工
|
||||
|
||||
施工基底经 `git ls-remote` 与认证仓库 API 双重核对,为完整 B2 `feat/hel-751-task-table@dca88b3765c507cba87a7c1da34f2309091e0588`。新分支 `fix/hel-767-sample-alignment`,PR 目标仍为该任务表格分支。PR #4/#5/#6 保持原目标、open、未合并。没有部署、改后端、修改用户配置或数据,没有增加依赖和检查例外。
|
||||
|
||||
原 HTML 通过 Multica 附件 `01a15ddd-040d-7e9a-a1a3-04932a3aa0c8` 下载,SHA256 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。只读运行该原件,未编辑或抽换样本。
|
||||
|
||||
## 变更与原因
|
||||
|
||||
- `ManagementPage.svelte` 和既有 `management.css`:在全部管理页统一头部恢复无文案、无交互、对辅助技术隐藏的右侧预留区域。使用字体、四个 em 空格、原件圆点占位和 padding 自然布局,继承 grade 页头字距;没有按主题补偿搜索坐标,没有演示徽标或假功能。
|
||||
- `ThemeChoices.svelte`:修复 tiles 标题与说明的字号/行高来源,标题 12.5px / 1.55,说明 11px / 1.55。改回样本纵向 flex 排版,文字区正常拉伸,不设固定卡片总高度。compact 弹层形态继续复用。`themes.css` 是对应排版 token 来源。
|
||||
- `AppShell.svelte`、`nav.ts`、`icons.ts`:在原组件和图标表内恢复品牌块、原件 SVG 路径和 1.7 描边、入口顺序、18px 图标、56px 宽入口、行距、分隔线、选中底座和左侧光条、底部队列/主题/设置/末槽的尺寸位置。恢复正确场景色和 day/grade 无光晕规则。去除侧栏继承的额外蜂窝悬停层,使用样本同一悬停来源。队列计数读取真实任务,运行环仅在真实 running 且未减少动效时显示,时长沿用原件;没有扩做 HEL-733 全局动效。
|
||||
- 保留原有导航展开、服务状态、所有产品路由和旧入口。原件最后的“规范”明确为非产品演示入口,该位置承载已有“导航”展开功能,复用原件图标和尺寸,不加入产品设计规范页。服务状态在模型连接提示和展开态保持可读。队列链接进入真实任务页;原顶栏 QueueTray 继续复用。
|
||||
- 小高度规则以浏览器 computed 为准:原 HTML 的早期 max-height 规则被后面的基础规则覆盖,三档窗口下原件实际都是 40px 品牌/队列、45px 入口;没有照抄一条无效规则造成小窗口压缩。
|
||||
|
||||
## 证据与复现
|
||||
|
||||
`scripts/verify-hel767.py` / `.mjs` 复用既有任务页隔离验证方式,创建临时 SQLite 与媒体夹具、动态本机静态服务器;所有 API 在精确本机 origin 下拦截,外部 origin 中止。未列出的写操作记录为失败并阻止。画布 ensure 仅返回隔离空图,不发送智能体消息或生成请求。退出 finally 关闭浏览器与服务器,Python TemporaryDirectory 删除 SQLite/视频。
|
||||
|
||||
准备已有的 Playwright 与浏览器运行库,令 `PLAYWRIGHT_MODULE` 指向其 `index.mjs`,必要时设置 `LD_LIBRARY_PATH`。先构建前端,再从仓库根运行 `python3 scripts/verify-hel767.py`。不将浏览器工具或运行库加入项目依赖。
|
||||
|
||||
截图矩阵为五个管理页 × night/day/grade/bluehour × 1440×900、1366×768、1280×720。样本与实际使用相同窗口、当前主题和减少动效模式;每组均有原件 PNG、实际 PNG、并排 PNG。`manifest.json` 列出图片文件名、字节数和 SHA256;`results.json` 保留 DOM 数字、完整断言、API 读写记录,`dimensions.md` 列样本/实际/差值。附加四主题悬停原图和操作录屏。
|
||||
|
||||
固定比较范围:任务搜索 x/y/宽高;全部四张主题卡片的 x/y/宽高、预览、标题、说明字号/行高;品牌、九个主/管理入口、分隔线、底部队列/主题/设置的位置;全部导航 SVG 路径、尺寸、描边、颜色及选中伪元素。其余管理区域截图供独立复核,不把真实数据差异当成结构完全相同。
|
||||
|
||||
## 已完成检查
|
||||
|
||||
干净 `npm ci`、全仓 `npm run lint`、`npm run check`(0 errors / 0 warnings)、四组既有前端守护 `npm test` 和 `npm run build` 通过。构建保留既有 @xyflow 未使用导入和超过 500kB chunk 提示,未扩大豁免。额外检查了修改的 Svelte 模板格式、脚本语法和 `git diff --check`。
|
||||
|
||||
后端相关回归 `test_studio_generation.py`、`test_playground.py`、`test_workflow_test.py`、`test_stop_kills_jobs.py`:19 passed,保留一条既有 Pydantic warning。后端源码未改。
|
||||
|
||||
独立交互回归 73 项通过,0 pageerror;全动效主题切换实测包含 `theme-lamp` 520ms,运行环 1600ms;减少动效立即应用且移除运行环。完整矩阵 60 组、6,169 条断言全部通过,0 pageerror。搜索框 12 组 x/y/宽高差为 0;全部四张主题卡片 × 12 组合(48 项)x/y/宽高差为 0,总高 130.421875px;标题行高 19.375px,说明 computed 行高 17.05px。侧栏入口、底部几何、图标和选中状态逐组合通过。数字原始结果见附件 `results.json` / `summary.json`。
|
||||
|
||||
## 真实数据和覆盖边界
|
||||
|
||||
本机夹具明确标为隔离验证,不写入产品。任务第二行沿真实项目来源契约显示项目,未伪造 API 不存在的智能体来源;自动列宽随文案变化,进度列 x 单列报告。未有输出的任务和视频缩略图使用同尺寸媒介图标,完成图片只显示夹具真实输出路径。队列是两项 running 加一项 pending,真实计数为 3,不写原件演示计数 2。
|
||||
|
||||
工作流、连接、素材的 API 列表由隔离契约夹具提供,不伪造用户真实目录、连接探测或素材。空连接/空素材状态与原件示例有内容差异,逐页对照保留。设置的真实语言、保存栏、高级选项与禁用“克制”档继续保留,隔离存储路径明确标记,不显示原件演示盘符。扫描导入真实入口保留,工作流三栏和任务整宽八列不变。
|
||||
|
||||
交互验证包含五个管理路由、兼容跳转、其他共用侧栏路由冒烟;任务搜索/筛选/错误恢复、取消/重试/下载/预览、手动视频播放/暂停/跳播/音量、弹层焦点与归还、删除撤销;创作台图片/视频提示词编辑与刷新草稿、Ctrl+K、无图工作流无上传格;侧栏展开/收起,四主题悬停、主题键盘选择/记忆和减少动效。
|
||||
|
||||
未覆盖真实 ComfyUI/GPU、付费云生成、用户数据库、全站全部历史业务、硬件扬声器与全屏,也未执行 HEL-731~733。此次交付须总管安排总工定向独立复核,不代表整套样本已验收,不合并或部署。
|
||||
|
||||
## 回滚
|
||||
|
||||
对本卡最终提交执行 `git revert <HEL-767 head>` 后正常推送,保留 B2 基底,不 reset/强推。无服务器或用户数据变更需要回滚。
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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")
|
||||
@@ -0,0 +1,936 @@
|
||||
/** Run via verify-hel767.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.HEL767_OUT,
|
||||
data = process.env.HEL767_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",
|
||||
});
|
||||
}
|
||||
const session = {
|
||||
id: 767,
|
||||
title: "HEL767 isolated session",
|
||||
project_id: null,
|
||||
origin: u.searchParams.get("origin") === "scene" ? "scene" : "chat",
|
||||
messages: [],
|
||||
plan: null,
|
||||
running: false,
|
||||
};
|
||||
const graph = {
|
||||
canvas: {
|
||||
id: 767,
|
||||
title: "HEL767 isolated canvas",
|
||||
project_id: null,
|
||||
agent_session_id: 767,
|
||||
viewport_json: null,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
};
|
||||
if (u.pathname === "/api/agent/sessions" && req.method() === "GET")
|
||||
return json([session]);
|
||||
if (u.pathname === "/api/agent/sessions/767") return json(session);
|
||||
if (u.pathname === "/api/canvas" && req.method() === "POST") {
|
||||
result.mutations.push({
|
||||
path: u.pathname,
|
||||
method: "POST",
|
||||
purpose: "isolated canvas ensure only",
|
||||
});
|
||||
return json(graph);
|
||||
}
|
||||
if (u.pathname === "/api/canvas/767") return json(graph);
|
||||
if (u.pathname === "/api/shots/by-session/767")
|
||||
return json({
|
||||
id: 767,
|
||||
title: "HEL767 isolated composition",
|
||||
scene: { objects: [], cameraTrack: [] },
|
||||
});
|
||||
if (u.pathname === "/api/shots/767/captures") return json([]);
|
||||
if (u.pathname === "/api/shots") return json([]);
|
||||
if (
|
||||
u.pathname === "/api/agent/projects" ||
|
||||
u.pathname === "/api/agent/skills"
|
||||
)
|
||||
return json([]);
|
||||
if (u.pathname === "/api/agent/sessions" && req.method() === "POST")
|
||||
return json(session);
|
||||
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 });
|
||||
result.errors.push(`Unexpected mutation ${req.method()} ${u.pathname}`);
|
||||
return route.fulfill({
|
||||
status: 503,
|
||||
body: "Unlisted fixture mutation blocked",
|
||||
});
|
||||
}
|
||||
result.apiReads.push(u.pathname);
|
||||
if (u.pathname === "/api/jobs") {
|
||||
if (apiError)
|
||||
return route.fulfill({ status: 500, body: "HEL767 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: 2,
|
||||
queue_poll_timeout_sec: 1800,
|
||||
queue_poll_interval_sec: 1,
|
||||
queue_max_retries: 1,
|
||||
data_dir: "HEL767-isolated-fixture",
|
||||
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", "reduce");
|
||||
});
|
||||
// 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,
|
||||
line: c.lineHeight,
|
||||
weight: c.fontWeight,
|
||||
shadow: c.boxShadow,
|
||||
svg: e.matches("svg") ? e.innerHTML : undefined,
|
||||
radius: c.borderRadius,
|
||||
color: c.color,
|
||||
background: c.backgroundColor,
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
selectors,
|
||||
);
|
||||
for (const [width, height] of process.env.HEL767_SKIP_VISUAL
|
||||
? []
|
||||
: [
|
||||
[1440, 900],
|
||||
[1366, 768],
|
||||
[1280, 720],
|
||||
])
|
||||
for (const theme of ["night", "day", "grade", "bluehour"])
|
||||
for (const [route, sample] of [
|
||||
["workflows", "workflows"],
|
||||
["connections", "models"],
|
||||
["tasks", "tasks"],
|
||||
["library", "library"],
|
||||
["settings", "settings"],
|
||||
]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await ref.setViewportSize({ width, height });
|
||||
await page.goto(base + "/" + route);
|
||||
await page.locator(".management-header").waitFor();
|
||||
if (route === "tasks") {
|
||||
await page.locator(".task-table tbody tr").first().waitFor();
|
||||
await progress();
|
||||
}
|
||||
if (route === "settings")
|
||||
await page.locator(".choices.tiles").waitFor();
|
||||
await page.evaluate((theme) => {
|
||||
localStorage.setItem("aigc-theme", theme);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
|
||||
);
|
||||
}, theme);
|
||||
await ref.evaluate(
|
||||
({ sample, theme }) => {
|
||||
go(sample);
|
||||
setTheme(theme);
|
||||
},
|
||||
{ sample, theme },
|
||||
);
|
||||
await page.waitForTimeout(650);
|
||||
const name = `${route}-${theme}-${width}`;
|
||||
const actualSelectors = {
|
||||
header: ".management-header",
|
||||
brand: ".refract-shell > .sidebar .brand",
|
||||
divider: ".refract-shell > .sidebar hr",
|
||||
foot: ".sidebar-bottom",
|
||||
queue: ".queue-dot",
|
||||
theme: ".sidebar-bottom button",
|
||||
settings: '.sidebar-bottom a[href="/settings"]',
|
||||
themeCard: ".tiles .choice",
|
||||
themePreview: ".tiles .preview",
|
||||
themeName: ".tiles .copy strong",
|
||||
themeTone: ".tiles .copy small",
|
||||
};
|
||||
const sampleSelectors = {
|
||||
header: `#p-${sample} .ph`,
|
||||
brand: ".rail .brand",
|
||||
divider: ".rail hr",
|
||||
foot: ".rail .foot",
|
||||
queue: ".rail .queue-dot",
|
||||
theme: "#themeBtn",
|
||||
settings: '.rail [data-go="settings"]',
|
||||
themeCard: ".ttile",
|
||||
themePreview: ".ttile .tprev",
|
||||
themeName: ".ttile b",
|
||||
themeTone: ".ttile small",
|
||||
};
|
||||
for (let i = 0; i < 9; i++) {
|
||||
actualSelectors[`nav${i}`] =
|
||||
`.refract-shell > .sidebar nav a:nth-child(${i < 6 ? i + 1 : i - 5})`;
|
||||
if (i >= 6)
|
||||
actualSelectors[`nav${i}`] =
|
||||
`.refract-shell > .sidebar .management a:nth-child(${i - 5})`;
|
||||
sampleSelectors[`nav${i}`] = `.rail > .nav:nth-of-type(${i + 1})`;
|
||||
}
|
||||
if (route === "settings")
|
||||
for (let i = 1; i <= 4; i++)
|
||||
for (const [key, a, b] of [
|
||||
["Card", ".choice", ".ttile"],
|
||||
["Preview", ".preview", ".tprev"],
|
||||
["Name", ".copy strong", "b"],
|
||||
["Tone", ".copy small", "small"],
|
||||
]) {
|
||||
actualSelectors[`theme${key}${i}`] =
|
||||
key === "Card"
|
||||
? `.tiles .choice:nth-child(${i})`
|
||||
: `.tiles .choice:nth-child(${i}) ${a}`;
|
||||
sampleSelectors[`theme${key}${i}`] =
|
||||
key === "Card"
|
||||
? `.ttile:nth-child(${i})`
|
||||
: `.ttile:nth-child(${i}) ${b}`;
|
||||
}
|
||||
if (route !== "settings")
|
||||
for (const key of [
|
||||
"themeCard",
|
||||
"themePreview",
|
||||
"themeName",
|
||||
"themeTone",
|
||||
]) {
|
||||
delete actualSelectors[key];
|
||||
delete sampleSelectors[key];
|
||||
}
|
||||
if (route === "tasks") {
|
||||
Object.assign(actualSelectors, {
|
||||
table: ".task-table",
|
||||
head: ".task-table thead",
|
||||
row: ".task-table tbody tr",
|
||||
thumbnail: ".task-thumbnail",
|
||||
segment: ".task-tabs",
|
||||
search: ".task-search",
|
||||
progress: ".task-progress",
|
||||
});
|
||||
Object.assign(sampleSelectors, {
|
||||
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",
|
||||
});
|
||||
}
|
||||
const railState = (p, actual) =>
|
||||
p.evaluate((actual) => {
|
||||
const selectors = actual
|
||||
? [
|
||||
".refract-shell > .sidebar nav a",
|
||||
'.sidebar-bottom a[href="/settings"]',
|
||||
".sidebar-bottom button:last-of-type",
|
||||
]
|
||||
: [
|
||||
".rail > .nav",
|
||||
'.rail [data-go="settings"]',
|
||||
'.rail [data-go="spec"]',
|
||||
];
|
||||
const nodes = [
|
||||
...document.querySelectorAll(selectors[0]),
|
||||
...selectors.slice(1).map((x) => document.querySelector(x)),
|
||||
];
|
||||
const geometry = (svg) => {
|
||||
const use = svg.querySelector("use");
|
||||
const root = use
|
||||
? document.querySelector(use.getAttribute("href"))
|
||||
: svg;
|
||||
return [...root.children].map((n) => [
|
||||
n.tagName,
|
||||
[...n.attributes].map((a) => [a.name, a.value]).sort(),
|
||||
]);
|
||||
};
|
||||
const selected = nodes.find((n) =>
|
||||
actual
|
||||
? n.getAttribute("aria-current") === "page"
|
||||
: n.classList.contains("on"),
|
||||
);
|
||||
const pseudo = selected
|
||||
? getComputedStyle(selected, "::before")
|
||||
: null;
|
||||
return {
|
||||
icons: nodes
|
||||
.filter((n) => n?.querySelector("svg"))
|
||||
.map((n) => {
|
||||
const svg = n.querySelector("svg"),
|
||||
c = getComputedStyle(svg);
|
||||
return {
|
||||
geometry: geometry(svg),
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
stroke: c.strokeWidth,
|
||||
color: c.color,
|
||||
};
|
||||
}),
|
||||
selected: pseudo
|
||||
? Object.fromEntries(
|
||||
[
|
||||
"left",
|
||||
"top",
|
||||
"bottom",
|
||||
"width",
|
||||
"borderRadius",
|
||||
"backgroundColor",
|
||||
"boxShadow",
|
||||
].map((k) => [k, pseudo[k]]),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}, actual);
|
||||
const railActual = await railState(page, true),
|
||||
railSample = await railState(ref, false);
|
||||
result.railProbe = { railActual, railSample };
|
||||
check(
|
||||
JSON.stringify(railActual) === JSON.stringify(railSample),
|
||||
`${name}: rail icon paths, sizes, strokes, colors and selected indicator match`,
|
||||
);
|
||||
const measured = {
|
||||
route,
|
||||
theme,
|
||||
width,
|
||||
height,
|
||||
railActual,
|
||||
railSample,
|
||||
actual: await measure(page, actualSelectors),
|
||||
sample: await measure(ref, sampleSelectors),
|
||||
};
|
||||
result.measurements.push(measured);
|
||||
await page.screenshot({ path: path.join(out, name + "-actual.png") });
|
||||
await ref.screenshot({ path: path.join(out, name + "-sample.png") });
|
||||
for (const key of Object.keys(sampleSelectors)) {
|
||||
if (key === "progress") continue; // auto columns depend on honest source text; reported separately
|
||||
for (const axis of ["x", "y", "width", "height"]) {
|
||||
// Table column width distribution is data-dependent, the outer table and thumbnail dimensions are fixed.
|
||||
if (key === "thumbnail" && axis === "x") continue;
|
||||
check(
|
||||
measured.actual[key] &&
|
||||
measured.sample[key] &&
|
||||
Math.abs(
|
||||
measured.actual[key][axis] - measured.sample[key][axis],
|
||||
) < 0.1,
|
||||
`${name}: ${key} ${axis} matches sample`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const key of [
|
||||
"brand",
|
||||
"queue",
|
||||
"theme",
|
||||
"settings",
|
||||
...Array.from({ length: 9 }, (_, i) => `nav${i}`),
|
||||
])
|
||||
check(
|
||||
measured.actual[key].radius === measured.sample[key].radius,
|
||||
`${name}: ${key} radius matches sample`,
|
||||
);
|
||||
if (route === "settings")
|
||||
for (const key of [
|
||||
"themeName",
|
||||
"themeTone",
|
||||
...Array.from({ length: 4 }, (_, i) => `themeName${i + 1}`),
|
||||
...Array.from({ length: 4 }, (_, i) => `themeTone${i + 1}`),
|
||||
])
|
||||
check(
|
||||
measured.actual[key].line === measured.sample[key].line,
|
||||
`${name}: ${key} line height`,
|
||||
);
|
||||
check(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
`${name}: no overflow`,
|
||||
);
|
||||
}
|
||||
await page.goto(base + "/tasks");
|
||||
await page.locator(".task-table tbody tr").first().waitFor();
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
if (process.env.HEL767_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("HEL767 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: "HEL767 workflow test" },
|
||||
output_paths: ["HEL767-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("HEL767 isolated creative draft");
|
||||
check(
|
||||
(await textarea.inputValue()) === "HEL767 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()) ===
|
||||
"HEL767 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",
|
||||
);
|
||||
}
|
||||
for (const route of [
|
||||
"/",
|
||||
"/workbench",
|
||||
"/agents",
|
||||
"/canvas",
|
||||
"/build-scene",
|
||||
"/models",
|
||||
"/model-connections",
|
||||
"/image",
|
||||
"/video",
|
||||
]) {
|
||||
await page.goto(base + route);
|
||||
await page.locator(".refract-shell > .sidebar").waitFor({ timeout: 15000 });
|
||||
await page.waitForTimeout(350);
|
||||
check(
|
||||
(await page.locator(".refract-shell > .sidebar").count()) === 1,
|
||||
`${route}: shared rail reachable`,
|
||||
);
|
||||
if (route === "/models")
|
||||
check(
|
||||
page.url().includes("/connections?tab=llm"),
|
||||
"legacy models redirect",
|
||||
);
|
||||
if (route === "/model-connections")
|
||||
check(page.url().includes("/connections"), "connections alias redirect");
|
||||
}
|
||||
await page.goto(base + "/create?kind=image");
|
||||
await page.locator(".studio-form textarea").first().waitFor();
|
||||
check(
|
||||
(await page.locator(".studio-form input[type=file]").count()) === 0,
|
||||
"t2i without image input keeps no padding slot",
|
||||
);
|
||||
await page.keyboard.press("Control+k");
|
||||
await page.getByRole("dialog").waitFor();
|
||||
check(true, "Ctrl+K opens global search");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
await page.goto(base + "/tasks");
|
||||
await page.locator(".task-table tbody tr").first().waitFor();
|
||||
await ref.setViewportSize({ width: 1440, height: 900 });
|
||||
for (const theme of ["night", "day", "grade", "bluehour"]) {
|
||||
await page.evaluate((theme) => {
|
||||
localStorage.setItem("aigc-theme", theme);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
|
||||
);
|
||||
}, theme);
|
||||
await ref.evaluate((theme) => {
|
||||
go("tasks");
|
||||
setTheme(theme);
|
||||
}, theme);
|
||||
await page.waitForTimeout(650);
|
||||
await page
|
||||
.locator('.refract-shell > .sidebar a[href="/workflows"]')
|
||||
.hover();
|
||||
await ref.locator('.rail [data-go="workflows"]').hover();
|
||||
await page.waitForTimeout(250);
|
||||
result.checks.push({
|
||||
name: `${theme}: rail hover`,
|
||||
pass: true,
|
||||
actual: await measure(page, {
|
||||
hover: '.refract-shell > .sidebar a[href="/workflows"]',
|
||||
}),
|
||||
sample: await measure(ref, { hover: '.rail [data-go="workflows"]' }),
|
||||
});
|
||||
const hover = result.checks.at(-1);
|
||||
for (const prop of ["color", "background", "radius"])
|
||||
check(
|
||||
hover.actual.hover[prop] === hover.sample.hover[prop],
|
||||
`${theme}: hover ${prop} matches sample`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(out, `hover-${theme}-actual.png`),
|
||||
});
|
||||
await ref.screenshot({ path: path.join(out, `hover-${theme}-sample.png`) });
|
||||
}
|
||||
await page.locator('.sidebar-bottom button[title="主题"]').click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
const firstChoice = page.getByRole("dialog").locator(".choice").first();
|
||||
await firstChoice.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("Enter");
|
||||
check(
|
||||
(await page.evaluate(() => document.documentElement.dataset.theme)) ===
|
||||
"day",
|
||||
"theme keyboard selection",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
check(
|
||||
await page
|
||||
.locator('.sidebar-bottom button[title="主题"]')
|
||||
.evaluate((e) => e === document.activeElement),
|
||||
"theme dialog restores focus",
|
||||
);
|
||||
await page.locator('button[title="展开导航"]').click();
|
||||
check(
|
||||
(await page.locator(".refract-shell.expanded").count()) === 1,
|
||||
"existing navigation expansion retained",
|
||||
);
|
||||
await page.locator('button[title="收起导航"]').click();
|
||||
await page.goto(base + "/tasks");
|
||||
await page.locator('.sidebar-bottom button[title="主题"]').click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.getByRole("dialog").getByRole("switch").click();
|
||||
await page.getByRole("dialog").locator(".choice").nth(3).click();
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.dataset.theme === "bluehour",
|
||||
);
|
||||
await page.waitForTimeout(50);
|
||||
result.themeTransition = await page.evaluate(() => ({
|
||||
theme: document.documentElement.dataset.theme,
|
||||
animations: document.getAnimations().map((a) => ({
|
||||
name: a.animationName,
|
||||
duration: a.effect?.getTiming().duration,
|
||||
})),
|
||||
reduced: document.documentElement.dataset.reduce,
|
||||
}));
|
||||
check(
|
||||
result.themeTransition.theme === "bluehour",
|
||||
"full-motion theme switch applies",
|
||||
);
|
||||
await page.waitForTimeout(650);
|
||||
check(
|
||||
(await page.locator(".queue-ring").count()) === 1,
|
||||
"real running jobs restore original queue ring in full motion",
|
||||
);
|
||||
await page.getByRole("dialog").getByRole("switch").click();
|
||||
check(
|
||||
(await page.locator(".queue-ring").count()) === 0,
|
||||
"reduced motion removes queue ring",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
check(result.errors.length === 0, "no browser page errors");
|
||||
const recordings = [await page.video().path(), await ref.video().path()];
|
||||
await context.close();
|
||||
result.recordings = recordings.map((p) => path.relative(out, p));
|
||||
} finally {
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
out,
|
||||
process.env.HEL767_SKIP_VISUAL
|
||||
? "interaction-results.json"
|
||||
: "results.json",
|
||||
),
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
await browser.close();
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""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 / "hel767-evidence"
|
||||
OUT.mkdir(exist_ok=True)
|
||||
|
||||
# This database exists only for the duration of this foreground verification.
|
||||
with tempfile.TemporaryDirectory(prefix="hel767-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": "hel767-fake", "name": "5090D"},
|
||||
},
|
||||
"output_paths": (
|
||||
["HEL767-fixture-4.png" if i == 4 else "HEL767-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": "HEL767 fixture prompt",
|
||||
"role": "prompt", "kind": "textarea", "defaultValue": "", "required": True,
|
||||
}],
|
||||
"output_schema": [{"nodeId": "2", "label": "Output", "kind": kind}],
|
||||
"endpoint_id": "hel767-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 | {"HEL767_OUT": str(OUT), "HEL767_DATA": data}
|
||||
subprocess.run(["node", "scripts/verify-hel767.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("HEL767_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"{measurement['route']}-{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]
|
||||
if not expected or not observed:
|
||||
continue
|
||||
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['route']} {measurement['theme']} {measurement['width']}×{measurement['height']} "
|
||||
f"| {key} | {values(expected)} | {values(observed)} | {values(delta)} |"
|
||||
)
|
||||
if key in ("themeName", "themeTone"):
|
||||
lines.append(f"| typography | {key} | {expected['font']} / {expected['line']} | {observed['font']} / {observed['line']} | font-size / line-height |")
|
||||
(OUT / "dimensions.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
import hashlib
|
||||
files = sorted(OUT.glob("*.png"))
|
||||
manifest = [{"file": f.name, "bytes": f.stat().st_size, "sha256": hashlib.sha256(f.read_bytes()).hexdigest()} for f in files]
|
||||
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
Reference in New Issue
Block a user