HEL-731: 工作室、项目五阶段与搭景入口按确认样本落地 #10
@@ -61,7 +61,7 @@
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
bottom: var(--toast-bottom, 24px);
|
||||
/* Above every overlay, incl. the 10000 lightbox, so feedback stays visible. */
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
<h3>2 · 准备这次创作</h3>
|
||||
{#if hasSlots && !dock}<div class="slots">
|
||||
<SlotTray
|
||||
allowScene={current?.kind === 'video'}
|
||||
bind:this={tray}
|
||||
bind:busy={slotBusy}
|
||||
{ds}
|
||||
@@ -374,6 +375,7 @@
|
||||
>
|
||||
<div class="media-editor">
|
||||
<SlotTray
|
||||
allowScene={current?.kind === 'video'}
|
||||
bind:this={tray}
|
||||
bind:busy={slotBusy}
|
||||
{ds}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { assetUrl } from "$lib/api";
|
||||
import { shotApi, type Capture } from "$lib/shot/api";
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
import { toast } from "$lib/toast";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onPick,
|
||||
}: { open?: boolean; onPick: (path: string) => void } = $props();
|
||||
let compositions = $state<Array<{ id: number; title: string }>>([]);
|
||||
let compositionId = $state("");
|
||||
let captures = $state<Capture[]>([]);
|
||||
let loading = $state(false);
|
||||
let request = 0;
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
request++;
|
||||
return;
|
||||
}
|
||||
const version = ++request;
|
||||
loading = true;
|
||||
shotApi
|
||||
.listCompositions()
|
||||
.then((rows) => {
|
||||
if (version !== request) return;
|
||||
compositions = rows;
|
||||
compositionId = rows.length ? String(rows[0].id) : "";
|
||||
})
|
||||
.catch((error) => toast.error(String(error)))
|
||||
.finally(() => {
|
||||
if (version === request) loading = false;
|
||||
});
|
||||
});
|
||||
$effect(() => {
|
||||
if (!open || !compositionId) {
|
||||
captures = [];
|
||||
return;
|
||||
}
|
||||
const id = Number(compositionId);
|
||||
let active = true;
|
||||
captures = [];
|
||||
shotApi.listCaptures(id).then((rows) => {
|
||||
if (active)
|
||||
captures = rows.filter((c) => c.kind === "image" && c.file_path);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="从三维搭景截取">
|
||||
<p>选择已保存的搭景截图,填入当前工作流声明的图片槽。</p>
|
||||
<a
|
||||
class="a-btn secondary sm"
|
||||
href={resolve("/build-scene")}
|
||||
target="_blank"
|
||||
rel="noopener">打开三维搭景</a
|
||||
>
|
||||
{#if loading}<p>正在读取截图…</p>{:else if !compositions.length}<p>
|
||||
暂无搭景,请先保存截图。
|
||||
</p>{:else}
|
||||
<label class="field"
|
||||
><span class="field-label">场景</span><select
|
||||
class="field-input"
|
||||
bind:value={compositionId}
|
||||
>{#each compositions as comp (comp.id)}<option value={String(comp.id)}
|
||||
>{comp.title}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<div class="capture-choices">
|
||||
{#each captures as capture (capture.id)}<button
|
||||
class="a-btn secondary"
|
||||
onclick={() => {
|
||||
if (capture.file_path) {
|
||||
onPick(capture.file_path);
|
||||
open = false;
|
||||
}
|
||||
}}
|
||||
><img
|
||||
src={assetUrl(capture.file_path) ?? ""}
|
||||
alt={capture.label ?? "搭景截图"}
|
||||
/><span>{capture.label ?? `截图 ${capture.id}`}</span></button
|
||||
>{:else}<p>该场景暂无图片截图。</p>{/each}
|
||||
</div>{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.capture-choices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-4);
|
||||
margin-top: var(--sp-5);
|
||||
}
|
||||
.capture-choices button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--sp-3);
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
.capture-choices img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import SceneCapturePicker from './SceneCapturePicker.svelte';
|
||||
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import { mediaFileProblem } from '$lib/comfy/imageFile';
|
||||
@@ -16,6 +17,7 @@
|
||||
*/
|
||||
interface Props {
|
||||
ds: DeskSchema;
|
||||
allowScene?: boolean;
|
||||
values: FormValues;
|
||||
showErrors: boolean;
|
||||
errors: Record<string, string>;
|
||||
@@ -25,7 +27,34 @@
|
||||
onFromResults: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
let { ds, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props();
|
||||
let { ds, allowScene = false, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props();
|
||||
|
||||
let sceneOpen = $state(false);
|
||||
let sceneNode = $state("");
|
||||
$effect(() => {
|
||||
void ds;
|
||||
sceneOpen = false;
|
||||
sceneNode = "";
|
||||
});
|
||||
function pickScene(path: string) {
|
||||
const slot = ds.slots.find(
|
||||
(s) => s.inp.nodeId === sceneNode && s.mediaKind === "image" && !s.mask,
|
||||
);
|
||||
const list = ds.lists.find(
|
||||
(g) => g.nodeId === sceneNode && g.kind === "image_list",
|
||||
);
|
||||
if (!allowScene) return;
|
||||
if (slot) onSet(sceneNode, path);
|
||||
else if (list) {
|
||||
const paths = listPaths(values, list);
|
||||
if (paths.length >= (list.max ?? 9)) {
|
||||
toast.error("参考图数量已达上限");
|
||||
return;
|
||||
}
|
||||
onSet(sceneNode, [...paths, path]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const uploads = createUploadManager();
|
||||
type Reject = { name: string; message: string };
|
||||
@@ -149,6 +178,7 @@
|
||||
if (unplaced) toast.error(t('desk.dropNoSlot', { n: unplaced }));
|
||||
}
|
||||
</script>
|
||||
<SceneCapturePicker bind:open={sceneOpen} onPick={pickScene}/>
|
||||
|
||||
{#if ds.slots.length || ds.lists.length}
|
||||
<div class="tray">
|
||||
@@ -169,6 +199,7 @@
|
||||
anchorId={`slot-${s.inp.nodeId}`}
|
||||
onFile={(f) => slotFile(s, f)}
|
||||
onFromResults={() => onFromResults(s.inp.nodeId)}
|
||||
onFromScene={allowScene && s.mediaKind === "image" && !s.mask ? () => { sceneNode = s.inp.nodeId; sceneOpen = true; } : undefined}
|
||||
onClear={() => {
|
||||
setRejects(s.inp.nodeId, []);
|
||||
onSet(s.inp.nodeId, '');
|
||||
@@ -193,6 +224,7 @@
|
||||
onReplace={(i, file) => groupReplace(entry, i, file)}
|
||||
onRemove={(i) => onSet(entry.nodeId, listPaths(values, entry).filter((_, j) => j !== i))}
|
||||
onDismissReject={(i) => setRejects(entry.nodeId, (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i))}
|
||||
onFromScene={allowScene && entry.kind === "image_list" ? () => { sceneNode = entry.nodeId; sceneOpen = true; } : undefined}
|
||||
onFromResults={entry.kind === 'image_list' ? () => onFromResults(entry.nodeId) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@
|
||||
* real queue and connection status. Individual tools keep their routes. */
|
||||
interface Props {
|
||||
workspace?: boolean;
|
||||
pageHeader?: import("svelte").Snippet;
|
||||
pageHeader?: import("svelte").Snippet<[{ openSearch: () => void }]>;
|
||||
active: NavId;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
@@ -282,7 +282,7 @@
|
||||
class="app-body"
|
||||
style:background={pageHeader ? "var(--manage-page-background)" : undefined}
|
||||
>
|
||||
{#if pageHeader}{@render pageHeader()}{:else}
|
||||
{#if pageHeader}{@render pageHeader({ openSearch: openPalette })}{:else}
|
||||
<header class="topbar">
|
||||
<div class="breadcrumb">
|
||||
{currentNav?.label ?? title}<span>/</span><span class="page-name"
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
anchorId?: string;
|
||||
onFile: (file: File) => void;
|
||||
onFromResults?: () => void;
|
||||
onFromScene?: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
@@ -48,6 +49,7 @@
|
||||
anchorId,
|
||||
onFile,
|
||||
onFromResults,
|
||||
onFromScene,
|
||||
onClear,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -108,6 +110,7 @@
|
||||
<span>{label}</span>
|
||||
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
||||
{#if hint}<span>{hint}</span>{/if}
|
||||
{#if onFromScene}<button type="button" class="a-btn ghost xs" onclick={onFromScene}>从三维搭景截取</button>{/if}
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
onRemove: (index: number) => void;
|
||||
onDismissReject: (index: number) => void;
|
||||
onFromResults?: () => void;
|
||||
onFromScene?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -64,6 +65,7 @@
|
||||
onRemove,
|
||||
onDismissReject,
|
||||
onFromResults,
|
||||
onFromScene,
|
||||
}: Props = $props();
|
||||
|
||||
let addInput = $state<HTMLInputElement | null>(null);
|
||||
@@ -133,6 +135,7 @@
|
||||
<span>{label}</span>
|
||||
<span class="req" aria-hidden="true">*</span>
|
||||
<span class="mono">{hintLabel}</span>
|
||||
{#if onFromScene}<button type="button" class="a-btn ghost xs" onclick={onFromScene}>从三维搭景截取</button>{/if}
|
||||
{#if allowFromResults && onFromResults && remaining > 0}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
|
||||
@@ -4,6 +4,31 @@
|
||||
* Each entry is SVG inner content rendered by Icon.svelte via {@html}.
|
||||
*/
|
||||
export const icons = {
|
||||
// Approved B3 sample geometry; retain existing tool icon contracts.
|
||||
"project-check": '<path d="M5 12.5 10 17l9-10"/>',
|
||||
"project-grid":
|
||||
'<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
||||
"project-book":
|
||||
'<path d="M4 4h6a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H4zM20 4h-5a3 3 0 0 0-3 3"/><path d="M20 4v14h-6"/>',
|
||||
"project-folder-in":
|
||||
'<path d="M3 6a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M12 10v6M9 13l3 3 3-3"/>',
|
||||
"project-film":
|
||||
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 4v16M17 4v16M2 9h5M2 15h5M17 9h5M17 15h5"/>',
|
||||
"project-image":
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>',
|
||||
"project-refresh": '<path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/>',
|
||||
"project-cube":
|
||||
'<path d="M12 2 3 7v10l9 5 9-5V7z"/><path d="M3 7l9 5 9-5M12 12v10"/>',
|
||||
"project-search": '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
|
||||
"project-arrow": '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
||||
"project-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"/>',
|
||||
"project-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"/>',
|
||||
"project-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"/>',
|
||||
"project-queue":
|
||||
'<path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="16" r="3"/>',
|
||||
"search-manage": '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
|
||||
"upload-manage": '<path d="M12 16V4M6 9l6-6 6 6M4 20h16"/>',
|
||||
"film-manage":
|
||||
|
||||
@@ -74,6 +74,12 @@
|
||||
function jobActive(job: WorkbenchAsset['job']): boolean {
|
||||
return !!job && (job.status === 'pending' || job.status === 'running');
|
||||
}
|
||||
|
||||
export function saveDraft() {
|
||||
if (editing) save();
|
||||
else if (stage.content) onSave?.(stage.content);
|
||||
}
|
||||
export function hasUnsavedDraft() { return editing; }
|
||||
</script>
|
||||
|
||||
{#if editing}
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
<script lang="ts">
|
||||
import "$lib/components/workbench/project.css";
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
import StudioHeader from "$lib/components/workbench/StudioHeader.svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import { goto } from "$app/navigation";
|
||||
import { untrack } from "svelte";
|
||||
import { page as pageStore } from "$app/state";
|
||||
import {
|
||||
createMutation,
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/svelte-query";
|
||||
import {
|
||||
agentApi,
|
||||
assetUrl,
|
||||
jobsApi,
|
||||
settings,
|
||||
workbenchApi,
|
||||
type WorkbenchStageKey,
|
||||
} from "$lib/api";
|
||||
import { connectEvents } from "$lib/events";
|
||||
import { parseDbTs } from "$lib/dbtime";
|
||||
import { handleJobEvent, progressFor } from "$lib/jobProgress";
|
||||
import { storageSet } from "$lib/storage";
|
||||
import { toast } from "$lib/toast";
|
||||
import AppShell from "$lib/components/shell/AppShell.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Skeleton from "$lib/components/ui/Skeleton.svelte";
|
||||
import StageStepper from "$lib/components/workbench/StageStepper.svelte";
|
||||
import ScriptStagePanel from "$lib/components/workbench/ScriptStagePanel.svelte";
|
||||
import StyleStagePanel from "$lib/components/workbench/StyleStagePanel.svelte";
|
||||
import StoryboardStagePanel from "$lib/components/workbench/StoryboardStagePanel.svelte";
|
||||
import AssetsStagePanel from "$lib/components/workbench/AssetsStagePanel.svelte";
|
||||
import ShotStagePanel from "$lib/components/workbench/ShotStagePanel.svelte";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
/**
|
||||
* 五段创作工作台 (a2): stepper + stage artifact panel + agent/task side
|
||||
* column. Only confirmed snapshots flow downstream; drafts are clearly
|
||||
* flagged and never treated as final. In-flow generation rides the shared
|
||||
* queue (HEL-612) so tasks and results come back to this project.
|
||||
*/
|
||||
|
||||
const client = useQueryClient();
|
||||
let { projectId: suppliedId }: { projectId: number } = $props();
|
||||
const projectId = untrack(() => suppliedId);
|
||||
let editor = $state<{saveDraft:()=>void;hasUnsavedDraft:()=>boolean}|null>(null);
|
||||
let compareOpen = $state(false);
|
||||
|
||||
const STAGE_ORDER: WorkbenchStageKey[] = [
|
||||
"script",
|
||||
"style",
|
||||
"storyboard",
|
||||
"assets",
|
||||
"shot",
|
||||
];
|
||||
|
||||
const overviewQuery = createQuery({
|
||||
queryKey: ["workbench", projectId],
|
||||
queryFn: () => workbenchApi.overview(projectId),
|
||||
refetchInterval: 2500,
|
||||
});
|
||||
const jobsQuery = createQuery({
|
||||
queryKey: ["jobs", projectId],
|
||||
queryFn: () => jobsApi.list(projectId),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const agentsQuery = createQuery({
|
||||
queryKey: ["agents-registry"],
|
||||
queryFn: agentApi.listNamedAgents,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const settingsQuery = createQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: settings.get,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const overview = $derived($overviewQuery.data);
|
||||
const stages = $derived(overview?.stages ?? []);
|
||||
|
||||
// ?stage= selects the open stage; default = first not-confirmed one.
|
||||
const urlStage = $derived(
|
||||
pageStore.url.searchParams.get("stage") as WorkbenchStageKey | null,
|
||||
);
|
||||
const fallbackStage = $derived.by(() => {
|
||||
for (const key of STAGE_ORDER) {
|
||||
const s = stages.find((x) => x.stage_key === key);
|
||||
if (!s || s.status !== "confirmed") return key;
|
||||
}
|
||||
return "shot";
|
||||
});
|
||||
const stageKey = $derived(
|
||||
urlStage && STAGE_ORDER.includes(urlStage) ? urlStage : fallbackStage,
|
||||
);
|
||||
const currentStage = $derived(stages.find((s) => s.stage_key === stageKey));
|
||||
|
||||
function selectStage(key: string) {
|
||||
if (Number(pageStore.params.id) !== projectId) return;
|
||||
noteOpen = false;
|
||||
const url = new URL(pageStore.url);
|
||||
url.searchParams.set("stage", key);
|
||||
goto(resolve(`/workbench/${pageStore.params.id}${url.search}${url.hash}`), {
|
||||
keepFocus: true,
|
||||
noScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
function advanceAfterConfirm(key: WorkbenchStageKey) {
|
||||
const idx = STAGE_ORDER.indexOf(key);
|
||||
if (idx >= 0 && idx < STAGE_ORDER.length - 1) {
|
||||
selectStage(STAGE_ORDER[idx + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Live job feed (shared queue) ───────────────────────────────────
|
||||
$effect(() => {
|
||||
const off = connectEvents((ev) => {
|
||||
handleJobEvent(ev);
|
||||
if (ev.type.startsWith("job.")) {
|
||||
client.invalidateQueries({ queryKey: ["jobs", projectId] });
|
||||
if (ev.type === "job.completed" || ev.type === "job.failed") {
|
||||
client.invalidateQueries({ queryKey: ["workbench", projectId] });
|
||||
client.invalidateQueries({ queryKey: ["projects"] });
|
||||
}
|
||||
}
|
||||
});
|
||||
return off;
|
||||
});
|
||||
|
||||
// ── Stage mutations ────────────────────────────────────────────────
|
||||
const instructions = $state<Record<string,string>>({});
|
||||
let runWatchKey = $state<WorkbenchStageKey | null>(null);
|
||||
let chosenStyleId = $state<string | null>(null);
|
||||
|
||||
let noteOpen = $state(false),
|
||||
noteText = $state(""),
|
||||
noteBusy = $state(false);
|
||||
$effect(() => {
|
||||
void stageKey;
|
||||
noteOpen = false;
|
||||
noteText = '';
|
||||
compareOpen = false;
|
||||
});
|
||||
async function saveNote() {
|
||||
noteBusy = true;
|
||||
try {
|
||||
await workbenchApi.saveNote(projectId, stageKey, noteText);
|
||||
await client.invalidateQueries({ queryKey: ["workbench", projectId] });
|
||||
noteOpen = false;
|
||||
toast.success("助手建议已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
noteBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the chosen style selector in sync with the stage payload.
|
||||
let lastStyleSync = $state("");
|
||||
$effect(() => {
|
||||
const styleStage = stages.find((s) => s.stage_key === "style");
|
||||
if (!styleStage) return;
|
||||
const directions = (
|
||||
styleStage.content as unknown as { directions?: { id: string }[] } | null
|
||||
)?.directions;
|
||||
const stamp = JSON.stringify(directions ?? []) + styleStage.chosen_style_id;
|
||||
if (stamp === lastStyleSync) return;
|
||||
lastStyleSync = stamp;
|
||||
const ids = (directions ?? []).map((d) => d.id);
|
||||
chosenStyleId =
|
||||
styleStage.chosen_style_id && ids.includes(styleStage.chosen_style_id)
|
||||
? styleStage.chosen_style_id
|
||||
: (ids[0] ?? null);
|
||||
});
|
||||
|
||||
// Toast once a watched run finishes (success or Chinese failure reason).
|
||||
$effect(() => {
|
||||
if (!runWatchKey) return;
|
||||
const stage = stages.find((s) => s.stage_key === runWatchKey);
|
||||
if (!stage || stage.status === "running") return;
|
||||
if (stage.status === "awaiting_confirm") {
|
||||
toast.success(t("workbench.runDone", { agent: agentName(runWatchKey) }));
|
||||
} else if (stage.last_run?.status === "failed" && stage.last_run.error) {
|
||||
toast.error(stage.last_run.error);
|
||||
}
|
||||
runWatchKey = null;
|
||||
});
|
||||
|
||||
function agentName(key: string): string {
|
||||
const agent = ($agentsQuery.data ?? []).find((a) => a.key === key);
|
||||
return agent ? agent.name : key;
|
||||
}
|
||||
|
||||
function busyFor(key: WorkbenchStageKey): boolean {
|
||||
const stage = stages.find((s) => s.stage_key === key);
|
||||
return stage?.status === "running";
|
||||
}
|
||||
|
||||
const runMutation = createMutation({
|
||||
mutationFn: (args: {key: WorkbenchStageKey; instruction: string}) =>
|
||||
workbenchApi.runStage(projectId, args.key, args.instruction || undefined),
|
||||
onSuccess: (_data, args) => {
|
||||
runWatchKey = args.key;
|
||||
if ((instructions[args.key] ?? '').trim() === args.instruction) instructions[args.key] = '';
|
||||
client.invalidateQueries({ queryKey: ["workbench", projectId] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("workbench.runFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = createMutation({
|
||||
mutationFn: (args: {
|
||||
key: WorkbenchStageKey;
|
||||
content: Record<string, unknown>;
|
||||
}) =>
|
||||
workbenchApi.patchStage(projectId, args.key, { content: args.content }),
|
||||
onSuccess: () => {
|
||||
toast.success(t("workbench.draftSaved"));
|
||||
client.invalidateQueries({ queryKey: ["workbench", projectId] });
|
||||
client.invalidateQueries({ queryKey: ["projects"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("workbench.saveFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const confirmMutation = createMutation({
|
||||
mutationFn: (key: WorkbenchStageKey) =>
|
||||
workbenchApi.confirmStage(
|
||||
projectId,
|
||||
key,
|
||||
key === "style" ? chosenStyleId : undefined,
|
||||
),
|
||||
onSuccess: (_data, key) => {
|
||||
toast.success(t("workbench.confirmed", { agent: agentName(key) }));
|
||||
client.invalidateQueries({ queryKey: ["workbench", projectId] });
|
||||
client.invalidateQueries({ queryKey: ["projects"] });
|
||||
advanceAfterConfirm(key);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("workbench.confirmFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const restoreMutation = createMutation({
|
||||
mutationFn: (key: WorkbenchStageKey) =>
|
||||
workbenchApi.restoreStage(projectId, key),
|
||||
onSuccess: () => {
|
||||
toast.success(t("workbench.restored"));
|
||||
client.invalidateQueries({ queryKey: ["workbench", projectId] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("workbench.restoreFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const importMutation = createMutation({
|
||||
mutationFn: (text: string) => workbenchApi.importScript(projectId, text),
|
||||
onSuccess: () => {
|
||||
toast.success(t("workbench.script.importDone"));
|
||||
client.invalidateQueries({ queryKey: ["workbench", projectId] });
|
||||
client.invalidateQueries({ queryKey: ["projects"] });
|
||||
selectStage("style");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("workbench.script.importFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const generateMutation = createMutation({
|
||||
mutationFn: (payload: {
|
||||
kind: "image" | "video";
|
||||
stage_key: WorkbenchStageKey;
|
||||
target_id?: string;
|
||||
prompt: string;
|
||||
negative_prompt?: string;
|
||||
duration_sec?: number;
|
||||
}) => workbenchApi.generate(projectId, payload),
|
||||
onSuccess: (data) => {
|
||||
toast.success(t("workbench.jobSubmitted"));
|
||||
client.invalidateQueries({ queryKey: ["workbench", projectId] });
|
||||
client.invalidateQueries({ queryKey: ["jobs", projectId] });
|
||||
void data;
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("workbench.jobSubmitFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const retryMutation = createMutation({
|
||||
mutationFn: (jobId: number) => jobsApi.retry(jobId),
|
||||
onSuccess: () => {
|
||||
toast.success(t("workbench.jobRequeued"));
|
||||
client.invalidateQueries({ queryKey: ["workbench", projectId] });
|
||||
client.invalidateQueries({ queryKey: ["jobs", projectId] });
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = createMutation({
|
||||
mutationFn: (jobId: number) => jobsApi.cancel(jobId),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ["jobs", projectId] });
|
||||
client.invalidateQueries({ queryKey: ["workbench", projectId] });
|
||||
},
|
||||
});
|
||||
|
||||
function onGenerate(payload: {
|
||||
kind: "image" | "video";
|
||||
targetId: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
durationSec?: number;
|
||||
}) {
|
||||
$generateMutation.mutate({
|
||||
kind: payload.kind,
|
||||
stage_key: stageKey,
|
||||
target_id: payload.targetId,
|
||||
prompt: payload.prompt,
|
||||
negative_prompt: payload.negativePrompt,
|
||||
duration_sec: payload.durationSec,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sidebar: agent + tasks ─────────────────────────────────────────
|
||||
const currentAgent = $derived(
|
||||
($agentsQuery.data ?? []).find((a) => a.key === stageKey) ?? null,
|
||||
);
|
||||
const currentModelName = $derived.by(() => {
|
||||
const agent = currentAgent;
|
||||
const s = $settingsQuery.data;
|
||||
if (!agent || !s) return null;
|
||||
const pid = s.agent_llm_assignments?.[agent.llm_role];
|
||||
const profile = (s.llm_profiles ?? []).find((p) => p.id === pid);
|
||||
return profile?.name ?? null;
|
||||
});
|
||||
|
||||
function openAgentChat() {
|
||||
if (!currentAgent) return;
|
||||
storageSet("calliope.agents.activeKey", currentAgent.key);
|
||||
goto(resolve("/agents"));
|
||||
}
|
||||
|
||||
const projectJobs = $derived(
|
||||
($jobsQuery.data ?? []).filter(
|
||||
(j) => j.project_id === projectId &&
|
||||
(j.kind === "image" || j.kind === "video") &&
|
||||
(j.payload?.stage_key ?? (j.payload?.workbench as {stage?:string}|undefined)?.stage) === stageKey,
|
||||
),
|
||||
);
|
||||
|
||||
function jobTime(raw: string | null | undefined): string {
|
||||
const ts = parseDbTs(raw);
|
||||
if (!Number.isFinite(ts)) return "";
|
||||
return new Date(ts).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function jobProgressPct(id: number): number | null {
|
||||
const entry = progressFor(id);
|
||||
return entry?.progress ?? null;
|
||||
}
|
||||
|
||||
// ── Header state helpers ───────────────────────────────────────────
|
||||
|
||||
const STAGE_LABEL: Record<string, string> = {
|
||||
script: "故事",
|
||||
style: "风格",
|
||||
storyboard: "分镜",
|
||||
assets: "素材",
|
||||
shot: "镜头脚本",
|
||||
};
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
todo: "workbench.state.todo",
|
||||
running: "workbench.state.running",
|
||||
awaiting_confirm: "workbench.state.draft",
|
||||
confirmed: "workbench.state.confirmed",
|
||||
};
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
todo: "",
|
||||
running: "run",
|
||||
awaiting_confirm: "warn",
|
||||
confirmed: "ok",
|
||||
};
|
||||
|
||||
const stageStatus = $derived(currentStage?.status ?? "todo");
|
||||
const isLastStage = $derived(stageKey === "shot");
|
||||
|
||||
const panelProps = $derived.by(() => {
|
||||
const stage = currentStage;
|
||||
if (!stage) return null;
|
||||
return { stage };
|
||||
});
|
||||
|
||||
function handleRun(key: WorkbenchStageKey) {
|
||||
$runMutation.mutate({key,instruction:(instructions[key] ?? '').trim()});
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppShell
|
||||
active="workbench"
|
||||
title={overview?.project.title ?? "项目"}
|
||||
workspace
|
||||
>
|
||||
{#snippet pageHeader({ openSearch })}<StudioHeader
|
||||
title={overview?.project.title ?? "项目"}
|
||||
project
|
||||
onSearch={openSearch}
|
||||
/>{/snippet}
|
||||
{#if !$overviewQuery.data && $overviewQuery.isLoading}<div class="project-load-pad">
|
||||
<Skeleton height="64px" /><Skeleton height="280px" />
|
||||
</div>
|
||||
{:else if !overview}<div class="project-load-pad">
|
||||
<p class="load-error">
|
||||
{t("workbench.loadFailed")}<button
|
||||
class="a-btn ghost"
|
||||
onclick={() => $overviewQuery.refetch()}
|
||||
>{t("workbench.retryLoad")}</button
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="project-workspace" data-testid="workbench-page">
|
||||
<StageStepper {stages} activeKey={stageKey} onSelect={selectStage} />
|
||||
<div class="project-body">
|
||||
<section class="project-main">
|
||||
{#if currentStage?.error || (currentStage?.last_run?.status === "failed" && currentStage?.last_run?.error)}<div
|
||||
class="error-banner"
|
||||
data-testid="stage-error"
|
||||
>
|
||||
{currentStage.last_run?.error ?? currentStage.error}
|
||||
</div>{/if}
|
||||
{#if stageStatus === "awaiting_confirm"}<div
|
||||
class="project-draftbar"
|
||||
data-testid="draft-banner"
|
||||
>
|
||||
<Icon name="project-agent" size={18} /><span
|
||||
><b>{currentAgent?.name ?? STAGE_LABEL[stageKey] + "助手"}</b
|
||||
>交回了本阶段草稿。确认后才会交给下一阶段使用。</span
|
||||
><span class="b3-grow"></span><button
|
||||
class="a-btn ghost sm"
|
||||
onclick={openAgentChat}>在智能体中打开</button
|
||||
><button
|
||||
class="a-btn secondary sm"
|
||||
disabled={!currentStage?.confirmed_content}
|
||||
onclick={() => (compareOpen = true)}>对比已确认版</button
|
||||
>
|
||||
</div>{/if}
|
||||
{#if stageStatus === "running"}<div
|
||||
class="project-draftbar"
|
||||
data-testid="running-banner"
|
||||
>
|
||||
<span class="breath"></span>{t("workbench.runningHint", {
|
||||
agent: agentName(stageKey),
|
||||
})}
|
||||
</div>{/if}
|
||||
<div class="project-content">
|
||||
{#if panelProps}
|
||||
<div class="stage-panel">
|
||||
{#if currentStage?.assistant_note || noteOpen}<details
|
||||
class="stage-note"
|
||||
open
|
||||
>
|
||||
<summary>助手建议 · 待整理到阶段内容</summary>
|
||||
{#if noteOpen}<textarea
|
||||
class="a-input"
|
||||
aria-label="编辑阶段助手建议"
|
||||
bind:value={noteText}
|
||||
></textarea>
|
||||
<div class="note-actions">
|
||||
<button
|
||||
class="a-btn ghost sm"
|
||||
disabled={noteBusy}
|
||||
onclick={() => (noteOpen = false)}>取消</button
|
||||
><button
|
||||
class="a-btn secondary sm"
|
||||
disabled={noteBusy}
|
||||
onclick={saveNote}>保存建议</button
|
||||
>
|
||||
</div>
|
||||
{:else}<p>{currentStage?.assistant_note}</p>
|
||||
<button
|
||||
class="a-btn ghost sm"
|
||||
disabled={busyFor(stageKey)}
|
||||
onclick={() => {
|
||||
noteText = currentStage?.assistant_note ?? "";
|
||||
noteOpen = true;
|
||||
}}>编辑建议</button
|
||||
>{/if}
|
||||
</details>{/if}
|
||||
{#if stageKey === "script"}
|
||||
<ScriptStagePanel
|
||||
bind:this={editor}
|
||||
stage={panelProps.stage}
|
||||
projectIdea={overview.project.idea}
|
||||
busy={busyFor("script")}
|
||||
onSave={(content) =>
|
||||
$saveMutation.mutate({ key: "script", content })}
|
||||
onImport={(text) => $importMutation.mutate(text)}
|
||||
onRun={() => handleRun("script")}
|
||||
/>
|
||||
{:else if stageKey === "style"}
|
||||
<StyleStagePanel
|
||||
bind:this={editor}
|
||||
stage={panelProps.stage}
|
||||
bind:chosenStyleId
|
||||
busy={busyFor("style")}
|
||||
onSave={(content) =>
|
||||
$saveMutation.mutate({ key: "style", content })}
|
||||
onRun={() => handleRun("style")}
|
||||
/>
|
||||
{:else if stageKey === "storyboard"}
|
||||
<StoryboardStagePanel
|
||||
bind:this={editor}
|
||||
{projectId}
|
||||
stage={panelProps.stage}
|
||||
busy={busyFor("storyboard")}
|
||||
onSave={(content) =>
|
||||
$saveMutation.mutate({ key: "storyboard", content })}
|
||||
onRun={() => handleRun("storyboard")}
|
||||
/>
|
||||
{:else if stageKey === "assets"}
|
||||
<AssetsStagePanel
|
||||
bind:this={editor}
|
||||
stage={panelProps.stage}
|
||||
{projectId}
|
||||
busy={busyFor("assets")}
|
||||
onSave={(content) =>
|
||||
$saveMutation.mutate({ key: "assets", content })}
|
||||
onRun={() => handleRun("assets")}
|
||||
{onGenerate}
|
||||
onRetryJob={(jobId) => $retryMutation.mutate(jobId)}
|
||||
/>
|
||||
{:else}
|
||||
<ShotStagePanel
|
||||
bind:this={editor}
|
||||
stage={panelProps.stage}
|
||||
{projectId}
|
||||
busy={busyFor("shot")}
|
||||
onSave={(content) =>
|
||||
$saveMutation.mutate({ key: "shot", content })}
|
||||
onRun={() => handleRun("shot")}
|
||||
{onGenerate}
|
||||
onRetryJob={(jobId) => $retryMutation.mutate(jobId)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
<aside class="project-side">
|
||||
<section class="project-assistant">
|
||||
<h3>本阶段助手</h3>
|
||||
<div class="project-agent">
|
||||
<span class="agent-icon"><Icon name="project-grid" size={20} /></span>
|
||||
<div class="b3-grow">
|
||||
<b>{currentAgent?.name ?? STAGE_LABEL[stageKey] + "助手"}</b
|
||||
><small
|
||||
>{currentModelName ?? "未配置模型"} · {currentAgent?.skill_count ??
|
||||
0} 个技能</small
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="a-btn ghost sm"
|
||||
disabled={!currentAgent}
|
||||
onclick={openAgentChat}>对话</button
|
||||
>
|
||||
</div>
|
||||
<div class="stage-input-wrap"><textarea
|
||||
class="stage-instruction"
|
||||
bind:value={instructions[stageKey]}
|
||||
placeholder="补充要求(可选):节奏更慢、第 3 镜改俯拍…"
|
||||
disabled={busyFor(stageKey)}
|
||||
data-testid="stage-instruction"
|
||||
></textarea></div>
|
||||
<div class="b3-row">
|
||||
<button
|
||||
class="a-btn secondary sm b3-grow"
|
||||
disabled={busyFor(stageKey) || $runMutation.isPending}
|
||||
onclick={() => handleRun(stageKey)}
|
||||
data-testid="stage-run-btn"
|
||||
><Icon name="project-refresh" size={14} />{stageStatus === "todo"
|
||||
? "生成本阶段"
|
||||
: "重新生成本阶段"}</button
|
||||
><button
|
||||
class="a-btn ghost sm"
|
||||
disabled={!currentStage?.confirmed_content ||
|
||||
busyFor(stageKey) ||
|
||||
$restoreMutation.isPending}
|
||||
onclick={() => $restoreMutation.mutate(stageKey)}
|
||||
data-testid="stage-restore-btn">恢复已确认版</button
|
||||
>
|
||||
</div>
|
||||
{#if stageKey === "shot"}<a
|
||||
class="a-btn secondary sm"
|
||||
href={resolve("/build-scene")}>三维搭景 · 截取参考图</a
|
||||
>{/if}
|
||||
</section>
|
||||
<section class="project-task-heading">
|
||||
<h3>阶段任务 <span class="a-tag">{projectJobs.length}</span></h3>
|
||||
<span class="b3-grow"></span><a
|
||||
class="a-btn ghost sm"
|
||||
href={resolve("/tasks")}>全部</a
|
||||
>
|
||||
</section>
|
||||
<div class="project-tasks">
|
||||
{#if projectJobs.length === 0}
|
||||
<p class="jobs-empty">{t("workbench.tasksEmpty")}</p>
|
||||
{:else}
|
||||
<ul class="jobs-list" data-testid="workbench-jobs">
|
||||
{#each projectJobs.slice(0, 8) as job (job.id)}
|
||||
{@const live = jobProgressPct(job.id)}
|
||||
<li class="job-row">
|
||||
<span class="job-kind kind-{job.kind}">
|
||||
<Icon
|
||||
name={job.kind === "image" ? "image" : "film"}
|
||||
size={12}
|
||||
/>
|
||||
</span>
|
||||
<span class="job-body">
|
||||
<span class="job-prompt"
|
||||
>{job.payload?.prompt ??
|
||||
job.payload?.source ??
|
||||
`#${job.id}`}</span
|
||||
>
|
||||
<span class="job-time mono"
|
||||
>{jobTime(job.created_at)}</span
|
||||
>
|
||||
</span>
|
||||
<span class="job-state st-{job.status}">
|
||||
{#if job.status === "running"}
|
||||
<span class="breath"></span>
|
||||
{#if live != null}{Math.round(live)}%{:else}{t(
|
||||
"workbench.jobRunning",
|
||||
)}{/if}
|
||||
{:else if job.status === "pending"}
|
||||
{t("workbench.jobQueued")}
|
||||
{:else if job.status === "done"}
|
||||
{t("workbench.jobDone")}
|
||||
{:else}
|
||||
{t("workbench.jobFailed")}
|
||||
{/if}
|
||||
</span>
|
||||
{#if job.status === "pending" || job.status === "running"}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title={t("workbench.cancelJob")}
|
||||
onclick={() => $cancelMutation.mutate(job.id)}
|
||||
>
|
||||
<Icon name="stop" size={12} />
|
||||
</button>
|
||||
{:else if job.status === "failed"}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title={t("workbench.retryJob")}
|
||||
onclick={() => $retryMutation.mutate(job.id)}
|
||||
>
|
||||
<Icon name="project-refresh" size={12} />
|
||||
</button>
|
||||
{:else if job.output_paths?.[0]}
|
||||
<a
|
||||
class="icon-btn"
|
||||
href={assetUrl(job.output_paths[0]) ?? "#"}
|
||||
target="_blank"
|
||||
rel="noopener external"
|
||||
title={t("workbench.openOutput")}
|
||||
>
|
||||
<Icon name="external-link" size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<footer class="project-footer">
|
||||
<span class="a-tag {STATUS_CLASS[stageStatus]}"
|
||||
>{t(STATUS_LABEL[stageStatus])}</span
|
||||
><span class="project-save-state"
|
||||
>{currentStage?.draft_edited
|
||||
? "已编辑 · 草稿待确认"
|
||||
: "确认后才会交给下一阶段使用"}</span
|
||||
><span class="b3-grow"></span><button class="a-btn secondary" disabled={busyFor(stageKey) || !currentStage?.content || $saveMutation.isPending} onclick={() => editor?.saveDraft()} data-testid="stage-save-btn">保存草稿</button><button
|
||||
class="a-btn primary"
|
||||
disabled={stageStatus !== "awaiting_confirm" ||
|
||||
$confirmMutation.isPending || !!editor?.hasUnsavedDraft()}
|
||||
onclick={() => $confirmMutation.mutate(stageKey)}
|
||||
data-testid="stage-confirm-btn"
|
||||
><Icon name="project-check" size={14} />{isLastStage
|
||||
? "确认镜头脚本"
|
||||
: `确认${STAGE_LABEL[stageKey]},进入${STAGE_LABEL[STAGE_ORDER[STAGE_ORDER.indexOf(stageKey) + 1]]}阶段`}</button
|
||||
>
|
||||
</footer>
|
||||
</div>{/if}
|
||||
</AppShell>
|
||||
<Modal bind:open={compareOpen} title="对比已确认版"
|
||||
><div class="project-compare">
|
||||
<section>
|
||||
<h3>已确认版</h3>
|
||||
<pre>{JSON.stringify(currentStage?.confirmed_content, null, 2)}</pre>
|
||||
</section>
|
||||
<section>
|
||||
<h3>当前草稿</h3>
|
||||
<pre>{JSON.stringify(currentStage?.content, null, 2)}</pre>
|
||||
</section>
|
||||
</div></Modal
|
||||
>
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
importOpen = false;
|
||||
importText = '';
|
||||
}
|
||||
|
||||
export function saveDraft() {
|
||||
if (editing) save();
|
||||
else if (stage.content) onSave?.(stage.content);
|
||||
}
|
||||
export function hasUnsavedDraft() { return editing; }
|
||||
</script>
|
||||
|
||||
{#if stage.status === 'todo' && !text}
|
||||
|
||||
@@ -67,6 +67,12 @@
|
||||
function jobActive(job: VideoShot['job']): boolean {
|
||||
return !!job && (job.status === 'pending' || job.status === 'running');
|
||||
}
|
||||
|
||||
export function saveDraft() {
|
||||
if (editing) save();
|
||||
else if (stage.content) onSave?.(stage.content);
|
||||
}
|
||||
export function hasUnsavedDraft() { return editing; }
|
||||
</script>
|
||||
|
||||
{#if editing}
|
||||
@@ -165,6 +171,7 @@
|
||||
<PromptBlock label={t('workbench.vshot.promptLabel')} text={shot.prompt} />
|
||||
<div class="vshot-foot">
|
||||
{#if projectId}
|
||||
<a class="a-btn ghost sm" href={resolve('/build-scene')}>三维搭景</a>
|
||||
<a
|
||||
class="a-btn ghost sm"
|
||||
href={resolve(deskHref({
|
||||
|
||||
@@ -1,343 +1,97 @@
|
||||
<script lang="ts">
|
||||
import type { WorkbenchStage } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { WorkbenchStage } from "$lib/api";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
/**
|
||||
* Five-stage stepper (a2): one step per agent with the fusion status
|
||||
* colors — gray=待开始, cyan=进行中 (+breath), amber=待确认,
|
||||
* green=已确认, --info=用户改过草稿. A step unlocks when itself or any
|
||||
* earlier stage has content; later steps stay clickable only after the
|
||||
* previous one is confirmed.
|
||||
*/
|
||||
interface Props {
|
||||
stages: WorkbenchStage[];
|
||||
activeKey: string;
|
||||
onSelect: (key: string) => void;
|
||||
}
|
||||
/**
|
||||
* Five-stage stepper (a2): one step per agent with the fusion status
|
||||
* colors — gray=待开始, cyan=进行中 (+breath), amber=待确认,
|
||||
* green=已确认, --info=用户改过草稿. A step unlocks when itself or any
|
||||
* earlier stage has content; later steps stay clickable only after the
|
||||
* previous one is confirmed.
|
||||
*/
|
||||
interface Props {
|
||||
stages: WorkbenchStage[];
|
||||
activeKey: string;
|
||||
onSelect: (key: string) => void;
|
||||
}
|
||||
|
||||
let { stages, activeKey, onSelect }: Props = $props();
|
||||
let { stages, activeKey, onSelect }: Props = $props();
|
||||
|
||||
const ORDER = ['script', 'style', 'storyboard', 'assets', 'shot'];
|
||||
const ORDER = ["script", "style", "storyboard", "assets", "shot"];
|
||||
|
||||
function label(key: string): string {
|
||||
return t(`workbench.stage.${key}`);
|
||||
}
|
||||
function label(key: string): string {
|
||||
return (
|
||||
{
|
||||
script: "故事",
|
||||
style: "风格",
|
||||
storyboard: "分镜",
|
||||
assets: "素材",
|
||||
shot: "镜头脚本",
|
||||
} as Record<string, string>
|
||||
)[key];
|
||||
}
|
||||
|
||||
function stateOf(
|
||||
stage: WorkbenchStage,
|
||||
): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
|
||||
if (stage.status === 'running') return 'running';
|
||||
if (stage.status === 'confirmed' && !stage.draft_edited) return 'confirmed';
|
||||
if (stage.status === 'confirmed' && stage.draft_edited) return 'edited';
|
||||
if (stage.status === 'awaiting_confirm')
|
||||
return stage.draft_edited ? 'edited' : 'draft';
|
||||
return 'todo';
|
||||
}
|
||||
function stateOf(
|
||||
stage: WorkbenchStage,
|
||||
): "todo" | "running" | "draft" | "edited" | "confirmed" {
|
||||
if (stage.status === "running") return "running";
|
||||
if (stage.status === "confirmed" && !stage.draft_edited) return "confirmed";
|
||||
if (stage.status === "confirmed" && stage.draft_edited) return "edited";
|
||||
if (stage.status === "awaiting_confirm")
|
||||
return stage.draft_edited ? "edited" : "draft";
|
||||
return "todo";
|
||||
}
|
||||
|
||||
function stateLabel(stage: WorkbenchStage): string {
|
||||
const s = stateOf(stage);
|
||||
if (s === 'running') return t('workbench.state.running');
|
||||
if (s === 'confirmed') return `✓ ${t('workbench.state.confirmed')}`;
|
||||
if (s === 'edited') return t('workbench.state.edited');
|
||||
if (s === 'draft') return t('workbench.state.draft');
|
||||
return t('workbench.state.todo');
|
||||
}
|
||||
function stateLabel(stage: WorkbenchStage): string {
|
||||
const s = stateOf(stage);
|
||||
if (s === "running") return t("workbench.state.running");
|
||||
if (s === "confirmed") return `✓ ${t("workbench.state.confirmed")}`;
|
||||
if (s === "edited") return t("workbench.state.edited");
|
||||
if (s === "draft") return t("workbench.state.draft");
|
||||
return t("workbench.state.todo");
|
||||
}
|
||||
|
||||
function unlocked(stages: WorkbenchStage[], key: string): boolean {
|
||||
const idx = ORDER.indexOf(key);
|
||||
if (idx <= 0) return true;
|
||||
const prev = stages.find((s) => s.stage_key === ORDER[idx - 1]);
|
||||
return prev?.status === 'confirmed';
|
||||
}
|
||||
function unlocked(stages: WorkbenchStage[], key: string): boolean {
|
||||
const idx = ORDER.indexOf(key);
|
||||
if (idx <= 0) return true;
|
||||
const prev = stages.find((s) => s.stage_key === ORDER[idx - 1]);
|
||||
return prev?.status === "confirmed";
|
||||
}
|
||||
</script>
|
||||
|
||||
<ol class="stepper" data-testid="workbench-stepper">
|
||||
{#each ORDER as key, i (key)}
|
||||
{@const stage = stages.find((s) => s.stage_key === key)}
|
||||
{@const state = stage ? stateOf(stage) : 'todo'}
|
||||
{@const open = stage ? unlocked(stages, key) : i === 0}
|
||||
<li class="step">
|
||||
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}
|
||||
></span>{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="step-btn"
|
||||
class:active={activeKey === key}
|
||||
class:locked={!open}
|
||||
data-stage={key}
|
||||
data-state={state}
|
||||
onclick={() => onSelect(key)}
|
||||
aria-current={activeKey === key ? 'step' : undefined}
|
||||
title={open
|
||||
? label(key)
|
||||
: t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
|
||||
>
|
||||
<span class="avatar"
|
||||
>{state === 'confirmed' ? '✓' : String(i + 1).padStart(2, '0')}</span
|
||||
>
|
||||
<span class="step-copy">
|
||||
<span class="step-name">{label(key)}</span>
|
||||
<span class="step-state st-{state}">
|
||||
{#if state === 'running'}<span class="breath" aria-hidden="true"
|
||||
></span>{/if}
|
||||
{stage ? stateLabel(stage) : t('workbench.state.todo')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{#each ORDER as key, i (key)}
|
||||
{@const stage = stages.find((s) => s.stage_key === key)}
|
||||
{@const state = stage ? stateOf(stage) : "todo"}
|
||||
{@const open = stage ? unlocked(stages, key) : i === 0}
|
||||
<li class="step">
|
||||
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}
|
||||
></span>{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="step-btn"
|
||||
class:active={activeKey === key}
|
||||
class:locked={!open}
|
||||
data-stage={key}
|
||||
data-state={state}
|
||||
onclick={() => onSelect(key)}
|
||||
aria-current={activeKey === key ? "step" : undefined}
|
||||
title={open
|
||||
? label(key)
|
||||
: t("workbench.lockedStep", { stage: label(ORDER[i - 1]) })}
|
||||
>
|
||||
<span class="avatar">{#if state === "confirmed"}<Icon name="project-check" size={18}/>{:else}{String(i + 1)}{/if}</span
|
||||
>
|
||||
<span class="step-copy">
|
||||
<span class="step-name">{label(key)}</span>
|
||||
<span class="step-state st-{state}">
|
||||
{#if state === "running"}<span class="breath" aria-hidden="true"
|
||||
></span>{/if}
|
||||
{stage ? stateLabel(stage) : t("workbench.state.todo")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 12px 0 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 26px;
|
||||
height: 2px;
|
||||
background: var(--surface-3);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.bar-done {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms;
|
||||
}
|
||||
.step-btn:hover:not(.locked) {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.step-btn.active {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
/* §3.2 当前段:金色下划 2px 内阴影 + 段名金色。 */
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
.step-btn.active .step-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
.step-btn[data-state='confirmed']:not(.active) .step-name {
|
||||
color: var(--success);
|
||||
}
|
||||
.step-btn.locked {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 58px;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.st-todo {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.st-running {
|
||||
color: var(--run);
|
||||
}
|
||||
.st-draft {
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-edited {
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-confirmed {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.step-btn.active[data-state='running'] .avatar,
|
||||
.step-btn[data-state='running'] .avatar {
|
||||
box-shadow: 0 0 10px var(--run-soft);
|
||||
}
|
||||
|
||||
.breath {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--run);
|
||||
animation: wb-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes wb-breath {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.breath {
|
||||
animation: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 7px;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.step {
|
||||
display: block;
|
||||
}
|
||||
.bar {
|
||||
display: none;
|
||||
}
|
||||
.step-btn {
|
||||
width: 100%;
|
||||
padding: 12px 10px;
|
||||
border: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.avatar {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
background: none !important;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 50%;
|
||||
font: 9px var(--mono);
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.step-btn.active {
|
||||
background: #e8eafe;
|
||||
box-shadow: none;
|
||||
}
|
||||
.step-btn.active .avatar {
|
||||
background: var(--accent) !important;
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.step-name {
|
||||
font-size: 11px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.step-state {
|
||||
font-size: 9px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.step-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
.step-btn.locked {
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.step-btn[data-state='confirmed'] .avatar {
|
||||
border-color: var(--ok);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 130px;
|
||||
}
|
||||
.bar {
|
||||
display: none;
|
||||
}
|
||||
.step-btn {
|
||||
width: 100%;
|
||||
border: 1px solid #ffffff10;
|
||||
border-radius: 10px;
|
||||
background: #12171c;
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
.step-btn.active {
|
||||
background: #00cae012;
|
||||
border-color: #00cae080;
|
||||
box-shadow: none;
|
||||
}
|
||||
.step-btn.active .avatar {
|
||||
background: #00cae012 !important;
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.step-btn.active .step-name {
|
||||
color: #86e8f1;
|
||||
}
|
||||
.step-state {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.step-btn[data-state='confirmed'] .avatar {
|
||||
color: var(--ok);
|
||||
}
|
||||
.step-btn.locked {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.step-btn:hover:not(.active) {
|
||||
background: #ffffff07;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,339 +1,349 @@
|
||||
<script lang="ts">
|
||||
import type { StoryboardShot, WorkbenchStage } from '$lib/api';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { resolve } from "$app/paths";
|
||||
import { assetUrl } from "$lib/api";
|
||||
import { deskHref } from "$lib/components/generate/context";
|
||||
import type { StoryboardShot, WorkbenchStage } from "$lib/api";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
interface Props {
|
||||
stage: WorkbenchStage;
|
||||
onSave?: (content: Record<string, unknown>) => void;
|
||||
onRun?: (instruction?: string) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
stage: WorkbenchStage;
|
||||
projectId?: number;
|
||||
onSave?: (content: Record<string, unknown>) => void;
|
||||
onRun?: (instruction?: string) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
let { stage, onSave, onRun, busy = false }: Props = $props();
|
||||
let { stage, projectId, onSave, onRun, busy = false }: Props = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draft = $state<StoryboardShot[]>([]);
|
||||
let editing = $state(false);
|
||||
let selectedShot = $state<string | null>(null);
|
||||
let draft = $state<StoryboardShot[]>([]);
|
||||
|
||||
const shots = $derived(
|
||||
Array.isArray((stage.content as { shots?: unknown } | null)?.shots)
|
||||
? ((stage.content as unknown as { shots: StoryboardShot[] }).shots ?? [])
|
||||
: [],
|
||||
);
|
||||
const totalDuration = $derived(shots.reduce((acc, s) => acc + (s.duration_sec || 0), 0));
|
||||
const shots = $derived(
|
||||
Array.isArray((stage.content as { shots?: unknown } | null)?.shots)
|
||||
? ((stage.content as unknown as { shots: StoryboardShot[] }).shots ?? [])
|
||||
: [],
|
||||
);
|
||||
const totalDuration = $derived(
|
||||
shots.reduce((acc, s) => acc + (s.duration_sec || 0), 0),
|
||||
);
|
||||
|
||||
function startEdit() {
|
||||
draft = shots.map((s) => ({ ...s }));
|
||||
editing = true;
|
||||
}
|
||||
function startEdit() {
|
||||
draft = shots.map((s) => ({ ...s }));
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
const clean = draft.filter((s) => s.description.trim());
|
||||
if (!clean.length) return;
|
||||
onSave?.({ shots: clean });
|
||||
editing = false;
|
||||
}
|
||||
function save() {
|
||||
const clean = draft.filter((s) => s.description.trim());
|
||||
if (!clean.length) return;
|
||||
onSave?.({ shots: clean });
|
||||
editing = false;
|
||||
}
|
||||
|
||||
function addShot() {
|
||||
draft = [
|
||||
...draft,
|
||||
{
|
||||
id: `shot-new-${draft.length + 1}-${Date.now() % 1000}`,
|
||||
scene: `${draft.length + 1}`,
|
||||
shot_size: '中景',
|
||||
camera: '固定',
|
||||
description: '',
|
||||
dialog: '',
|
||||
duration_sec: 3,
|
||||
},
|
||||
];
|
||||
}
|
||||
function addShot() {
|
||||
draft = [
|
||||
...draft,
|
||||
{
|
||||
id: `shot-new-${draft.length + 1}-${Date.now() % 1000}`,
|
||||
scene: `${draft.length + 1}`,
|
||||
shot_size: "中景",
|
||||
camera: "固定",
|
||||
description: "",
|
||||
dialog: "",
|
||||
duration_sec: 3,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function removeShot(id: string) {
|
||||
draft = draft.filter((s) => s.id !== id);
|
||||
}
|
||||
function removeShot(id: string) {
|
||||
draft = draft.filter((s) => s.id !== id);
|
||||
}
|
||||
|
||||
export function saveDraft() {
|
||||
if (editing) save();
|
||||
else if (stage.content) onSave?.(stage.content);
|
||||
}
|
||||
export function hasUnsavedDraft() { return editing; }
|
||||
</script>
|
||||
|
||||
{#if editing}
|
||||
<div class="editor">
|
||||
<div class="edit-list">
|
||||
{#each draft as shot, i (shot.id)}
|
||||
<div class="shot-edit">
|
||||
<div class="shot-edit-head">
|
||||
<span class="shot-index mono">{String(i + 1).padStart(2, '0')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
title={t('workbench.board.remove')}
|
||||
onclick={() => removeShot(shot.id)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="shot-fields">
|
||||
<input class="a-input w-xs" bind:value={shot.scene} placeholder={t('workbench.board.scene')} />
|
||||
<input class="a-input w-xs" bind:value={shot.shot_size} placeholder={t('workbench.board.size')} />
|
||||
<input class="a-input w-xs" bind:value={shot.camera} placeholder={t('workbench.board.camera')} />
|
||||
<input
|
||||
class="a-input w-xs mono"
|
||||
type="number"
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
bind:value={shot.duration_sec}
|
||||
placeholder={t('workbench.board.duration')}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
class="a-input"
|
||||
rows="2"
|
||||
bind:value={shot.description}
|
||||
placeholder={t('workbench.board.descPh')}
|
||||
></textarea>
|
||||
<input class="a-input" bind:value={shot.dialog} placeholder={t('workbench.board.dialogPh')} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<button type="button" class="a-btn ghost" onclick={addShot}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t('workbench.board.add')}
|
||||
</button>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn primary"
|
||||
disabled={!draft.some((s) => s.description.trim())}
|
||||
onclick={save}
|
||||
>
|
||||
{t('workbench.saveDraft')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor">
|
||||
<div class="edit-list">
|
||||
{#each draft as shot, i (shot.id)}
|
||||
<div class="shot-edit">
|
||||
<div class="shot-edit-head">
|
||||
<span class="shot-index mono">{String(i + 1).padStart(2, "0")}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
title={t("workbench.board.remove")}
|
||||
onclick={() => removeShot(shot.id)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="shot-fields">
|
||||
<input
|
||||
class="a-input w-xs"
|
||||
bind:value={shot.scene}
|
||||
placeholder={t("workbench.board.scene")}
|
||||
/>
|
||||
<input
|
||||
class="a-input w-xs"
|
||||
bind:value={shot.shot_size}
|
||||
placeholder={t("workbench.board.size")}
|
||||
/>
|
||||
<input
|
||||
class="a-input w-xs"
|
||||
bind:value={shot.camera}
|
||||
placeholder={t("workbench.board.camera")}
|
||||
/>
|
||||
<input
|
||||
class="a-input w-xs mono"
|
||||
type="number"
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
bind:value={shot.duration_sec}
|
||||
placeholder={t("workbench.board.duration")}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
class="a-input"
|
||||
rows="2"
|
||||
bind:value={shot.description}
|
||||
placeholder={t("workbench.board.descPh")}
|
||||
></textarea>
|
||||
<input
|
||||
class="a-input"
|
||||
bind:value={shot.dialog}
|
||||
placeholder={t("workbench.board.dialogPh")}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<button type="button" class="a-btn ghost" onclick={addShot}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t("workbench.board.add")}
|
||||
</button>
|
||||
<div class="foot-btns">
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn ghost"
|
||||
onclick={() => (editing = false)}
|
||||
>
|
||||
{t("workbench.cancelEdit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn primary"
|
||||
disabled={!draft.some((s) => s.description.trim())}
|
||||
onclick={save}
|
||||
>
|
||||
{t("workbench.saveDraft")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if shots.length === 0}
|
||||
<div class="todo-card">
|
||||
<p class="todo-title">{t('workbench.board.todoTitle')}</p>
|
||||
<p class="todo-body">{t('workbench.board.todoBody')}</p>
|
||||
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.board.generate')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="todo-card">
|
||||
<p class="todo-title">{t("workbench.board.todoTitle")}</p>
|
||||
<p class="todo-body">{t("workbench.board.todoBody")}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn primary"
|
||||
disabled={busy}
|
||||
onclick={() => onRun?.()}
|
||||
>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t("workbench.board.generate")}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="board-table" data-testid="storyboard-table">
|
||||
<div class="bt-head">
|
||||
<span class="col-no">#</span>
|
||||
<span class="col-scene">{t('workbench.board.scene')}</span>
|
||||
<span class="col-size">{t('workbench.board.size')}</span>
|
||||
<span class="col-camera">{t('workbench.board.camera')}</span>
|
||||
<span class="col-desc">{t('workbench.board.desc')}</span>
|
||||
<span class="col-dialog">{t('workbench.board.dialog')}</span>
|
||||
<span class="col-dur mono">{t('workbench.board.duration')}</span>
|
||||
</div>
|
||||
{#each shots as shot, i (shot.id)}
|
||||
<div class="bt-row">
|
||||
<span class="col-no mono">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span class="col-scene">{shot.scene}</span>
|
||||
<span class="col-size"><span class="tag">{shot.shot_size}</span></span>
|
||||
<span class="col-camera">{shot.camera}</span>
|
||||
<span class="col-desc">{shot.description}</span>
|
||||
<span class="col-dialog">{shot.dialog || '—'}</span>
|
||||
<span class="col-dur mono">{shot.duration_sec}s</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="view-foot">
|
||||
<span class="hint mono">{t('workbench.board.summary', { n: shots.length, sec: totalDuration })}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('workbench.regenerate')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="storyboard-grid" data-testid="storyboard-table">
|
||||
{#each shots as shot, i (shot.id)}
|
||||
{@const media = (shot as StoryboardShot & { job?: { output?: string } })
|
||||
.job?.output}
|
||||
<article class="storyboard-card" class:selected={selectedShot === shot.id}>
|
||||
<button type="button" class="storyboard-image" aria-label={`选择镜头 ${i+1}`} aria-pressed={selectedShot === shot.id} onclick={() => selectedShot = shot.id}>
|
||||
{#if media}<img
|
||||
src={assetUrl(media) ?? ""}
|
||||
alt={shot.description}
|
||||
/>{:else}<span class="storyboard-missing">暂无草图</span>{/if}<span
|
||||
class="shot-time"
|
||||
>{String(i + 1).padStart(2, "0")} · {shot.duration_sec}s</span
|
||||
><span class="shot-media-state a-tag">{media ? "草图" : "待画"}</span>
|
||||
</button>
|
||||
<div class="storyboard-copy">
|
||||
<div class="b3-row">
|
||||
<b>{shot.shot_size}</b><span class="shot-scene"
|
||||
>场 {shot.scene}</span
|
||||
>
|
||||
</div>
|
||||
<p>{shot.description}</p>
|
||||
<div class="b3-row">
|
||||
<span class="shot-dialog" title={shot.dialog}
|
||||
>台词 {shot.dialog || "—"}</span
|
||||
><span class="b3-grow"></span>{#if projectId}<a
|
||||
class="a-btn ghost sm"
|
||||
href={resolve(
|
||||
deskHref({
|
||||
kind: "video",
|
||||
projectId,
|
||||
stage: "storyboard",
|
||||
label: `分镜 ${i + 1}`,
|
||||
prompt: shot.description,
|
||||
durationSec: shot.duration_sec,
|
||||
}),
|
||||
)}><Icon name="project-film" size={14} />去生视频</a
|
||||
>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="view-foot">
|
||||
<span class="hint mono"
|
||||
>{t("workbench.board.summary", {
|
||||
n: shots.length,
|
||||
sec: totalDuration,
|
||||
})}</span
|
||||
>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== "running"}
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn ghost"
|
||||
disabled={busy}
|
||||
onclick={startEdit}
|
||||
>
|
||||
<Icon name="edit" size={13} />
|
||||
{t("workbench.editDraft")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn ghost"
|
||||
disabled={busy}
|
||||
onclick={() => onRun?.()}
|
||||
>
|
||||
<Icon name="retry" size={13} />
|
||||
{t("workbench.regenerate")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.todo-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 22px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.todo-title {
|
||||
margin: 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.todo-body {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.todo-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 22px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.todo-title {
|
||||
margin: 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.todo-body {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.board-table {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.bt-head,
|
||||
.bt-row {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 64px 76px 76px minmax(220px, 1fr) minmax(140px, 0.8fr) 56px;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 10px 14px;
|
||||
min-width: 680px;
|
||||
}
|
||||
.bt-head {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.bt-row {
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
}
|
||||
.bt-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.col-no {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.col-scene,
|
||||
.col-camera {
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-desc {
|
||||
color: var(--text);
|
||||
}
|
||||
.col-dialog {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.col-dur {
|
||||
text-align: right;
|
||||
color: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-2);
|
||||
font-size: 10.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.edit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.shot-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.shot-edit-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.shot-index {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.shot-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.a-input.mono {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.a-input.w-xs {
|
||||
width: 110px;
|
||||
flex: 1;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.edit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.shot-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.shot-edit-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.shot-index {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.shot-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.a-input.mono {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.a-input.w-xs {
|
||||
width: 110px;
|
||||
flex: 1;
|
||||
min-width: 96px;
|
||||
}
|
||||
.editor-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.view-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.foot-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.icon-btn.danger:hover {
|
||||
background: var(--error-dim);
|
||||
color: var(--error);
|
||||
}
|
||||
.view-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.foot-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.icon-btn.danger:hover {
|
||||
background: var(--error-dim);
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
let {
|
||||
title,
|
||||
project = false,
|
||||
onSearch,
|
||||
}: { title: string; project?: boolean; onSearch: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<header class="studio-header">
|
||||
<div class="studio-crumb">
|
||||
{#if project}<a href={resolve("/")}>工作室</a><span>/</span>{/if}<b
|
||||
>{title}</b
|
||||
>
|
||||
</div>
|
||||
{#if !project}<span class="studio-greeting">晚上好,今天从哪里继续?</span
|
||||
>{/if}
|
||||
<span class="b3-grow"></span>
|
||||
{#if project}<a class="a-btn ghost sm" href={resolve("/canvas")}
|
||||
><Icon name="project-canvas" size={14} />灵感画布</a
|
||||
><a class="a-btn ghost sm" href={resolve("/library")}
|
||||
><Icon name="project-lib" size={14} />项目素材</a
|
||||
>{:else}<button
|
||||
class="studio-search"
|
||||
onclick={onSearch}
|
||||
aria-keyshortcuts="Control+K Meta+K"
|
||||
><Icon name="project-search" size={14} /><span>搜索项目、工作流、素材…</span><kbd
|
||||
>Ctrl K</kbd
|
||||
></button
|
||||
>{/if}
|
||||
</header>
|
||||
@@ -59,6 +59,12 @@
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function saveDraft() {
|
||||
if (editing) save();
|
||||
else if (stage.content) onSave?.(stage.content);
|
||||
}
|
||||
export function hasUnsavedDraft() { return editing; }
|
||||
</script>
|
||||
|
||||
{#if editing}
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
.home,
|
||||
.home *,
|
||||
.studio-header,
|
||||
.studio-header *,
|
||||
.project-workspace,
|
||||
.project-workspace * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* HEL-731: approved sample 01/08/16, shared by studio and project panels.
|
||||
Theme values live in static/themes.css; no per-page theme overrides. */
|
||||
.b3-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.b3-grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.b3-empty {
|
||||
color: var(--tx-3);
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
.home {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--project-side-width);
|
||||
gap: 20px;
|
||||
padding: 20px 24px 28px;
|
||||
max-width: 1680px;
|
||||
--scene: var(--gold);
|
||||
}
|
||||
.home .main-col {
|
||||
min-width: 0;
|
||||
}
|
||||
.studio-hero {
|
||||
position: relative;
|
||||
border-radius: var(--project-hero-radius);
|
||||
overflow: hidden;
|
||||
min-height: 250px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--studio-hero-bg);
|
||||
}
|
||||
.hero-art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.hero-shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--bg-0) 96%, transparent) 0%,
|
||||
color-mix(in srgb, var(--bg-0) 85%, transparent) 38%,
|
||||
color-mix(in srgb, var(--bg-0) 15%, transparent) 75%,
|
||||
color-mix(in srgb, var(--bg-0) 30%, transparent)
|
||||
);
|
||||
}
|
||||
.hero-copy {
|
||||
position: relative;
|
||||
padding: 26px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
.studio-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: var(--project-eyebrow-radius);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
width: max-content;
|
||||
color: color-mix(in srgb, var(--scene) 75%, var(--hi));
|
||||
border: 1px solid color-mix(in srgb, var(--scene) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--scene) 10%, transparent);
|
||||
}
|
||||
.studio-display {
|
||||
font: 700 var(--fs-display)/1.25 var(--project-display-font);
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0;
|
||||
}
|
||||
.studio-display em {
|
||||
font-style: normal;
|
||||
background: var(--display-grad);
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero-copy > .b3-row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.hero-description {
|
||||
color: var(--tx-2);
|
||||
max-width: 420px;
|
||||
margin: 0;
|
||||
}
|
||||
.studio-stages {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
max-width: 440px;
|
||||
}
|
||||
.studio-stages div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.studio-stages i {
|
||||
height: 3px;
|
||||
border-radius: 3px;
|
||||
background: rgb(var(--ink) / 0.12);
|
||||
}
|
||||
.studio-stages .done i {
|
||||
background: var(--gold);
|
||||
}
|
||||
.studio-stages .cur i {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--gold) 50%,
|
||||
rgb(var(--ink) / 0.12) 50%
|
||||
);
|
||||
}
|
||||
.studio-stages .done,
|
||||
.studio-stages .cur {
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.studio-quick {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.studio-quick > * {
|
||||
border-radius: var(--project-card-radius);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--t-base) var(--ease-out),
|
||||
border-color var(--t-base);
|
||||
color: var(--tx-1);
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
}
|
||||
.studio-quick > *:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--scene) 45%, transparent);
|
||||
}
|
||||
.studio-quick .qi {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--scene) 15%, transparent);
|
||||
color: var(--scene);
|
||||
}
|
||||
.studio-quick b {
|
||||
font-size: 14px;
|
||||
}
|
||||
.studio-quick p {
|
||||
font-size: 12px;
|
||||
color: var(--tx-3);
|
||||
margin: 0;
|
||||
}
|
||||
.home .projects-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 24px 0 12px;
|
||||
}
|
||||
.home .projects-head h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.studio-filters {
|
||||
display: flex;
|
||||
width: 200px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 9px;
|
||||
padding: 3px;
|
||||
}
|
||||
.studio-filters button {
|
||||
flex: 1;
|
||||
background: none;
|
||||
color: var(--tx-3);
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
border-radius: 7px;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.studio-filters .on {
|
||||
background: var(--bg-3);
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.home .cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.studio-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.studio-side-card {
|
||||
border-radius: var(--project-card-radius);
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.studio-side-card h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.studio-side-card small {
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.waiting-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--sc-agent);
|
||||
box-shadow: 0 0 8px var(--sc-agent);
|
||||
}
|
||||
.home .confirm-item {
|
||||
border-radius: var(--project-confirm-radius);
|
||||
border: 1px solid color-mix(in srgb, var(--sc-agent) 35%, transparent);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--sc-agent) 10%, transparent),
|
||||
transparent
|
||||
);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.studio-outputs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.studio-outputs a {
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.studio-outputs img,
|
||||
.studio-outputs video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.home .panel-empty {
|
||||
padding: 24px;
|
||||
color: var(--tx-3);
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: var(--project-card-radius);
|
||||
text-align: center;
|
||||
}
|
||||
.home .job-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.home .job-row {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.home .job-top,
|
||||
.home .job-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.home .job-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.home .job-meta {
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.home .job-status {
|
||||
font-size: 11px;
|
||||
}
|
||||
.home .failed-text {
|
||||
color: var(--err);
|
||||
}
|
||||
.home .running-text {
|
||||
color: var(--sc-video);
|
||||
}
|
||||
.home .done-text {
|
||||
color: var(--ok);
|
||||
}
|
||||
.home .retry-link {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.project-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
--scene: var(--sc-project);
|
||||
}
|
||||
.project-workspace .stepper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 0 var(--s4);
|
||||
list-style: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
flex: none;
|
||||
height: 58px;
|
||||
}
|
||||
.project-workspace .step {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
.project-workspace .bar {
|
||||
display: none;
|
||||
}
|
||||
.project-workspace .step-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
color: var(--tx-3);
|
||||
transition: color var(--t-base);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.project-workspace .step-btn:hover,
|
||||
.project-workspace .step-btn.active {
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.project-workspace .avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font: 700 11px var(--mono);
|
||||
border: 1px solid var(--line-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.project-workspace .step-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.project-workspace .step-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.project-workspace .step-state {
|
||||
font-size: 11px;
|
||||
}
|
||||
.project-workspace [data-state="confirmed"] .avatar {
|
||||
background: color-mix(in srgb, var(--gold) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--gold) 50%, transparent);
|
||||
color: var(--gold);
|
||||
}
|
||||
.project-workspace [data-state="draft"] .avatar,
|
||||
.project-workspace [data-state="edited"] .avatar {
|
||||
border-color: var(--sc-agent);
|
||||
color: var(--sc-agent);
|
||||
box-shadow: var(--project-draft-shadow);
|
||||
}
|
||||
.project-workspace .step-btn.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
background: var(--gold);
|
||||
box-shadow: var(--project-active-shadow);
|
||||
}
|
||||
.project-workspace .step + .step::before {
|
||||
content: "";
|
||||
align-self: center;
|
||||
width: 12px;
|
||||
margin: 0 -6px;
|
||||
height: 1px;
|
||||
background: var(--line-3);
|
||||
z-index: 1;
|
||||
}
|
||||
.project-body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--project-side-width);
|
||||
min-height: 0;
|
||||
}
|
||||
.project-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.project-draftbar {
|
||||
margin: 14px 18px 0;
|
||||
border-radius: var(--project-draft-radius);
|
||||
padding: 10px 12px 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--sc-agent) 14%, transparent),
|
||||
color-mix(in srgb, var(--sc-agent) 4%, transparent)
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--sc-agent) 35%, transparent);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.project-draftbar > svg {
|
||||
color: var(--sc-agent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.project-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 14px 18px 18px;
|
||||
}
|
||||
.project-side {
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--project-side-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.project-assistant {
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.project-assistant h3,
|
||||
.project-task-heading h3 {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.project-agent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: var(--project-draft-radius);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--sc-agent) 12%, transparent),
|
||||
rgb(var(--ink) / 0.02)
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--sc-agent) 25%, transparent);
|
||||
}
|
||||
.project-agent b {
|
||||
font-size: 13px;
|
||||
}
|
||||
.project-agent small {
|
||||
display: block;
|
||||
color: var(--tx-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.agent-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--sc-agent) 20%, transparent);
|
||||
color: var(--sc-agent);
|
||||
}
|
||||
.stage-input-wrap {
|
||||
height: 70px;
|
||||
color: var(--tx-3);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--well);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.stage-instruction {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
resize: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font: 400 12.5px/1.55 var(--font);
|
||||
color: var(--tx-1);
|
||||
padding: 0;
|
||||
}
|
||||
.stage-instruction::placeholder {
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.project-task-heading {
|
||||
padding: 14px 14px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.project-tasks {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.project-tasks .jobs-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.project-tasks .job-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.project-tasks .job-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 12px;
|
||||
}
|
||||
.project-tasks .job-prompt {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.project-tasks .job-time,
|
||||
.project-tasks .job-state {
|
||||
color: var(--tx-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.project-tasks .jobs-empty {
|
||||
color: var(--tx-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.project-footer {
|
||||
flex: none;
|
||||
min-height: var(--project-footer-height);
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 18px;
|
||||
background: color-mix(in srgb, var(--bg-1) 85%, transparent);
|
||||
}
|
||||
.project-save-state {
|
||||
font-size: 12px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.project-workspace .stage-note {
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--project-card-radius);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.project-workspace .note-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.project-workspace .stage-note textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
}
|
||||
.project-workspace .stage-note p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.project-compare {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.project-compare pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.project-workspace .error-banner {
|
||||
color: var(--err);
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.project-load-pad {
|
||||
padding: 24px;
|
||||
}
|
||||
.project-workspace .icon-btn {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.storyboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
.storyboard-card {
|
||||
border-radius: var(--project-shot-radius);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color var(--t-base),
|
||||
transform var(--t-base) var(--ease-out);
|
||||
}
|
||||
.storyboard-card:hover {
|
||||
border-color: var(--line-3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.storyboard-card.selected {
|
||||
border-color: color-mix(in srgb, var(--gold) 50%, transparent);
|
||||
}
|
||||
.storyboard-image {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
aspect-ratio: 16/9;
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--gen-bg);
|
||||
}
|
||||
.storyboard-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.storyboard-missing {
|
||||
font-size: 12px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.shot-time {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
font: 700 11px var(--mono);
|
||||
background: var(--media-scrim);
|
||||
color: var(--media-text);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.shot-media-state {
|
||||
--tag-background: var(--project-media-tag-bg);
|
||||
--tag-color: var(--project-media-tag-color);
|
||||
--tag-border: var(--project-media-tag-border);
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
}
|
||||
.storyboard-copy {
|
||||
padding: 9px 11px 11px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.storyboard-copy p {
|
||||
color: var(--tx-2);
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.shot-scene,
|
||||
.shot-dialog {
|
||||
color: var(--tx-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.shot-dialog {
|
||||
max-width: 50%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.studio-header {
|
||||
height: var(--head);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: 0 var(--s4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--project-header-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
.studio-crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--tx-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.studio-crumb b {
|
||||
color: var(--tx-1);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.studio-crumb a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.studio-greeting {
|
||||
font-size: 12px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.studio-search {
|
||||
width: 280px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--well);
|
||||
border: 1px solid var(--line-2);
|
||||
padding: 0 10px;
|
||||
color: var(--tx-3);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.studio-search kbd {
|
||||
margin-left: auto;
|
||||
font: 500 10.5px/1 var(--mono);
|
||||
color: var(--tx-3);
|
||||
border: 1px solid var(--line-2);
|
||||
border-bottom-width: 2px;
|
||||
padding: 3px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
/* Configure the existing controls at the page boundary, keeping their source shared. */
|
||||
.home,
|
||||
.project-workspace,
|
||||
.studio-header {
|
||||
--icon-stroke: var(--project-icon-stroke);
|
||||
--button-height: 32px;
|
||||
--button-font-size: 12.5px;
|
||||
--button-weight: 500;
|
||||
--button-radius: var(--project-button-radius);
|
||||
--tag-height: 18px;
|
||||
--tag-padding: 6px;
|
||||
--tag-radius: 4px;
|
||||
--tag-font-size: 11px;
|
||||
--tag-border-width: 1px;
|
||||
--tag-border: var(--line);
|
||||
--tag-background: rgb(var(--ink) / 0.05);
|
||||
--tag-color: var(--tx-2);
|
||||
--tag-warn-color: var(--sc-agent);
|
||||
--tag-warn-background: rgb(var(--ink) / 0.05);
|
||||
--tag-warn-border: color-mix(in srgb, var(--sc-agent) 40%, transparent);
|
||||
--button-sm-font-size: 12px;
|
||||
--button-sm-height: 26px;
|
||||
--button-sm-padding: 9px;
|
||||
--button-sm-radius: var(--project-button-sm-radius);
|
||||
--button-primary-height: 32px;
|
||||
--button-primary-padding: 12px;
|
||||
--button-primary-radius: var(--project-button-radius);
|
||||
--button-primary-weight: 700;
|
||||
--button-primary-border-width: 0px;
|
||||
--button-primary-disabled-opacity: 1;
|
||||
--button-primary-disabled-filter: var(--project-button-disabled-filter);
|
||||
--button-primary-disabled-bg: var(--project-button-disabled-bg);
|
||||
--button-primary-disabled-color: var(--project-button-disabled-color);
|
||||
--button-secondary-bg: var(--bg-3);
|
||||
--button-secondary-border: var(--line-2);
|
||||
--button-secondary-hover-border: var(--line-3);
|
||||
--button-secondary-padding: 12px;
|
||||
--button-secondary-active-transform: translateY(1px);
|
||||
--button-secondary-radius: var(--project-button-radius);
|
||||
}
|
||||
+804
-1173
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,7 @@ code,
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: var(--button-height, 34px);
|
||||
padding: 0 var(--sp-6);
|
||||
padding: 0 var(--button-padding, var(--sp-6));
|
||||
border-radius: var(--button-radius, var(--r-md));
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
@@ -94,8 +94,12 @@ code,
|
||||
.a-btn.sm {
|
||||
min-height: var(--button-sm-height, 28px);
|
||||
padding: 0 var(--button-sm-padding, var(--sp-4));
|
||||
font-size: var(--fs-aux);
|
||||
font-size: var(--button-sm-font-size, var(--fs-aux));
|
||||
border-radius: var(--button-sm-radius, var(--r-sm));
|
||||
--button-secondary-size-radius: var(
|
||||
--button-sm-radius,
|
||||
var(--button-secondary-radius, 8px)
|
||||
);
|
||||
}
|
||||
.a-btn.xs {
|
||||
min-height: 24px;
|
||||
@@ -107,7 +111,7 @@ code,
|
||||
width: 100%;
|
||||
}
|
||||
.a-btn.primary {
|
||||
border-color: transparent;
|
||||
border: var(--button-primary-border-width, 1px) solid transparent;
|
||||
border-bottom-width: 0;
|
||||
min-height: var(--button-primary-height, 40px);
|
||||
padding: 0 var(--button-primary-padding, 18px);
|
||||
@@ -119,8 +123,16 @@ code,
|
||||
}
|
||||
.a-btn.secondary {
|
||||
background: var(--button-secondary-bg, var(--surface));
|
||||
border-radius: var(--button-secondary-radius, 8px);
|
||||
border-color: var(--line);
|
||||
border-radius: var(
|
||||
--button-secondary-size-radius,
|
||||
var(--button-secondary-radius, 8px)
|
||||
);
|
||||
border-color: var(--button-secondary-border, var(--line));
|
||||
--button-padding: var(--button-secondary-padding, var(--sp-6));
|
||||
--button-active-transform: var(
|
||||
--button-secondary-active-transform,
|
||||
scale(var(--press-scale))
|
||||
);
|
||||
color: var(--text);
|
||||
}
|
||||
.a-btn.ghost {
|
||||
@@ -143,7 +155,7 @@ code,
|
||||
box-shadow: var(--btn-glow-hover);
|
||||
}
|
||||
.a-btn.secondary:hover:not(:disabled) {
|
||||
border-color: var(--line-2);
|
||||
border-color: var(--button-secondary-hover-border, var(--line-2));
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.a-btn.ghost:hover:not(:disabled) {
|
||||
@@ -160,7 +172,7 @@ code,
|
||||
}
|
||||
}
|
||||
.a-btn:active:not(:disabled) {
|
||||
transform: scale(var(--press-scale));
|
||||
transform: var(--button-active-transform, scale(var(--press-scale)));
|
||||
}
|
||||
/* 禁用必须在旁边给出中文原因(§1.2),由调用方渲染。 */
|
||||
.a-btn:disabled,
|
||||
@@ -170,6 +182,15 @@ code,
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Primary disabled state is configurable only where a page supplies the tokens. */
|
||||
.a-btn.primary:disabled,
|
||||
.a-btn.primary[aria-disabled="true"] {
|
||||
opacity: var(--button-primary-disabled-opacity, 0.45);
|
||||
filter: var(--button-primary-disabled-filter, none);
|
||||
background: var(--button-primary-disabled-bg, var(--accent-grad));
|
||||
color: var(--button-primary-disabled-color, var(--accent-ink));
|
||||
}
|
||||
|
||||
/* Top-bar status capsule (queue / connection / save state) — §二 顶栏胶囊. */
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
@@ -247,12 +268,13 @@ button.status-chip:active {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
height: 20px;
|
||||
padding: 0 var(--sp-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-4);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
height: var(--tag-height, 20px);
|
||||
padding: 0 var(--tag-padding, var(--sp-2));
|
||||
border: var(--tag-border-width, 0px) solid var(--tag-border, transparent);
|
||||
border-radius: var(--tag-radius, var(--r-sm));
|
||||
background: var(--tag-background, var(--bg-4));
|
||||
color: var(--tag-color, var(--text-2));
|
||||
font-size: var(--tag-font-size, var(--fs-aux));
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
@@ -276,8 +298,9 @@ button.status-chip:active {
|
||||
background: var(--err-soft);
|
||||
}
|
||||
.a-tag.warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
color: var(--tag-warn-color, var(--warn));
|
||||
background: var(--tag-warn-background, var(--warn-soft));
|
||||
border-color: var(--tag-warn-border, transparent);
|
||||
}
|
||||
|
||||
/* Real-progress bar (§1.3):<div class="a-progress" style="--p:.42"><i></i></div>.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,30 @@
|
||||
--fs-body: 13px;
|
||||
--fs-small: 12px;
|
||||
--fs-micro: 11px;
|
||||
--studio-hero-bg: #0b0c11;
|
||||
--project-footer-height: 56px;
|
||||
--project-side-width: 340px;
|
||||
--project-media-tag-bg: rgba(7, 8, 12, 0.62);
|
||||
--project-media-tag-color: #ddd;
|
||||
--project-media-tag-border: rgba(255, 255, 255, 0.16);
|
||||
--project-icon-stroke: 1.7;
|
||||
--project-hero-radius: var(--r-xl);
|
||||
--project-button-radius: var(--r-sm);
|
||||
--project-button-sm-radius: 5px;
|
||||
--project-button-disabled-filter: grayscale(0.8) brightness(0.6);
|
||||
--project-button-disabled-bg: var(--grad-gold);
|
||||
--project-button-disabled-color: var(--tx-inv);
|
||||
--project-header-bg: var(--glass);
|
||||
--project-eyebrow-radius: var(--r-pill);
|
||||
--project-draft-shadow: 0 0 12px
|
||||
color-mix(in srgb, var(--sc-agent) 35%, transparent);
|
||||
--project-shot-radius: 12px;
|
||||
--project-active-shadow: 0 0 12px var(--gold);
|
||||
--project-side-bg: var(--bg-1);
|
||||
--project-display-font: var(--font);
|
||||
--project-card-radius: var(--r-lg);
|
||||
--project-confirm-radius: 10px;
|
||||
--project-draft-radius: 12px;
|
||||
--slot-size: 56px;
|
||||
--prompt-height: 60px;
|
||||
--desk-dock-radius: 14px;
|
||||
@@ -277,6 +301,11 @@
|
||||
}
|
||||
|
||||
[data-theme="day"] {
|
||||
--project-button-disabled-filter: none;
|
||||
--project-button-disabled-bg: #e3ddd2;
|
||||
--project-button-disabled-color: #8d877d;
|
||||
--project-active-shadow: none;
|
||||
--project-draft-shadow: none;
|
||||
--rail-active-shadow: none;
|
||||
color-scheme: light;
|
||||
--bg-0: #e9e4da;
|
||||
@@ -360,6 +389,14 @@
|
||||
}
|
||||
|
||||
[data-theme="grade"] {
|
||||
--project-card-radius: 4px;
|
||||
--project-hero-radius: 4px;
|
||||
--project-eyebrow-radius: 3px;
|
||||
--project-confirm-radius: 10px;
|
||||
--project-draft-radius: 4px;
|
||||
--project-shot-radius: 4px;
|
||||
--project-active-shadow: none;
|
||||
--project-draft-shadow: none;
|
||||
--rail-active-shadow: none;
|
||||
--rail-nav-radius: 4px;
|
||||
--rail-brand-radius: 4px;
|
||||
@@ -460,6 +497,11 @@
|
||||
}
|
||||
|
||||
[data-theme="bluehour"] {
|
||||
--project-shot-radius: 14px;
|
||||
--project-button-radius: 10px;
|
||||
--project-button-sm-radius: 8px;
|
||||
--project-header-bg: color-mix(in srgb, #0f1838 55%, transparent);
|
||||
--project-side-bg: color-mix(in srgb, var(--bg-1) 78%, transparent);
|
||||
--rail-nav-radius: 14px;
|
||||
--desk-head-bg: color-mix(in srgb, #0f1838 55%, transparent);
|
||||
color-scheme: dark;
|
||||
@@ -596,6 +638,7 @@
|
||||
--composer-bg: var(--desk-dock-bg);
|
||||
--composer-focus: var(--bg-2);
|
||||
--float-bg: var(--bg-2);
|
||||
--toast-bottom: 24px;
|
||||
--on-media: #fff;
|
||||
--on-media-line: #ffffff66;
|
||||
--sel-shadow: 0 0 0 1px var(--gold);
|
||||
@@ -750,3 +793,8 @@ html.theme-anim *::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep transient feedback clear of the project’s fixed primary actions. */
|
||||
:root:has(.project-workspace) {
|
||||
--toast-bottom: calc(var(--project-footer-height) + 24px);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
HEL-731(B3)将工作室与项目五阶段接到已确认的夜片场四主题样本。基底固定为 `fix/hel-771-theme-tile-details @ a1c01f1e90b0b1ce76300291eae51785604180d4`,不合并既有请求、不部署、不迁移数据。
|
||||
|
||||
- 工作室首页改为真实项目继续创作、四个入口、项目筛选、待确认、队列与媒体墙。移除旧 hero 和重复覆盖规则。没有数据时显示空态,不写死样本项目、计数或图片。
|
||||
- `ProjectWorkbench.svelte` 复用五阶段 API 与已有编辑组件;顶部阶段条、右侧助手、镜头缩略图网格、固定保存和确认、确认快照对比。编辑中禁止确认旧内容。阶段要求分别保存;任务同时按项目和阶段过滤。路由按项目 ID 重建组件,异步完成不能把导航或输入带到另一项目。
|
||||
- 镜头脚本保留完整项目/阶段/镜头来源的创作台入口及原有显式结果回流,新增搭景入口。分镜仅携带真实分镜上下文和提示词预填,不伪造可回流的镜头脚本 ID。
|
||||
- 视频声明的非遮罩图片槽及参考图片列表可选择真实搭景截图,遵守列表数量上限;无图声明的文生图仍无图片/遮罩槽。复用 Modal、MediaSlot、RefImageGrid、shotApi,未新增依赖或生成 API。
|
||||
- 所有布局样式集中在 `project.css`,主题差异集中在 `themes.css`。样本图标加入已有 Icon 注册表,旧图标保持原契约。AppShell 页头 snippet 新增搜索回调;全局搜索快捷键继续由原组件负责。共享按钮、标签仅新增可配置 token,默认值保持原样;AProjectCard 同时影响项目列表,保留改名/删除。
|
||||
|
||||
验证:干净 `npm ci`;lint、Svelte check(0 errors / 0 warnings)、build、前端脚本测试;工作台生命周期和来源回流 pytest 18 passed。B3 浏览器 64 项、创作台 42 项、无图声明 7 项,运行异常均为 0。旧 B1 脚本将 B2 已移除的 `.wf-test-btn` 选择器更新为真实“试运行”按钮,等待数据加载,保留原行为断言,无检查豁免。
|
||||
|
||||
四主题 × 三尺寸 × 工作室/项目共 24 组原始样本与实际并排图,附 DOM 数值 CSV/JSON、原始样本、操作截图和 Playwright trace。三尺寸的页头、阶段条、右栏、底栏、按钮及输入几何按样本对齐。1280 档原始样本的描述文字较长,hero 和镜头卡会多换一行;证据保留原图与原始数值,另附仅替换这两处描述为隔离夹具文字的复测,24 组高度差均为 0。真实项目数、阶段状态、版本、产物和空态不替换为演示数据。
|
||||
|
||||
已验证真实 Three.js 截图 → 工作流声明槽 → 假 ComfyUI 视频生成 → 原镜头显式回流;另一项目不收到产物;项目视频默认暂停且可手动播放。五阶段生成/确认/恢复/导入通过隔离 pytest;保存、确认、恢复、对比、阶段切换、长内容滚动、主题与搜索通过浏览器操作。
|
||||
|
||||
限制:只使用隔离库和假生成服务,未调用真实 GPU 或收费服务。主题卡追加 144 组回归,DOM 差异 0;136 组原始截图逐像素一致,另外 8 组仍有 1–13 个像素、最大色阶差 2,原图全部保留,不据此宣布视觉通过。未部署,需总工独立审核;B4/B5 与整套样本仍未完成。pytest 有既存 Pydantic 字段 alias 警告,build 有依赖内未使用导入提示;lint/check 均无警告。回滚仅需切回上述精确基底,无数据库回滚步骤。
|
||||
|
||||
复测:安装仓库既有依赖后,设置本机 Playwright/Chromium 库路径,运行 `python3 e2e/run-hel731.py`;用 `HEL731_TESTS=e2e/hel731.cjs,e2e/hel729.cjs,e2e/hel742.cjs` 同时运行相关回归。样本通过 CLI 下载至上级 `attachments`,SHA-256 为 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。随后运行 `node e2e/hel731-normalize.mjs`、`python3 scripts/report-hel731.py` 生成对照与数值证据。
|
||||
@@ -0,0 +1,14 @@
|
||||
HEL-774 在 HEL-731 / PR #10 上按确认 HTML 样本定点返工,目标仍为 `fix/hel-771-theme-tile-details @ a1c01f1e90b0b1ce76300291eae51785604180d4`。
|
||||
|
||||
- `project.css` 两页作用域的次级按钮改回 `--bg-3`、`--line-2`、水平 12px;悬停边框为样本 `--line-3`,按下为样本 `translateY(1px)`。小按钮保留既有 9px,主按钮仍为 12px。
|
||||
- `app.css` 仅为现有共享按钮增加带原值回退的 token 参数。尺寸规则仍由原 sm/lg/xs 定义负责,没有追加第二套组件样式,也未改动主题配色。
|
||||
- 移除 `.confirm-item` 的 12.5px,按样本继承 13px。两页现有 `.a-tag` 均仅含文本;逐一实测将 gap 从 4px 改为 0px 后宽度不变,保留共享标签契约。
|
||||
- `.sec-h`、日期内容/格式、后端、确认语义、B4/B5 均未改动。
|
||||
|
||||
复测使用 `e2e/run-hel774.py` 和 `e2e/hel774.cjs`,复用既有依赖与隔离假服务;四主题三尺寸两页共 24 组原始截图、次级按钮状态数值/局部图、空态和保存/跳转操作 trace 随 HEL-774 交付。测试中“禁用”包含显式标记的临时 DOM 状态夹具;未将样本项目、媒体或数字写入产品。原始页面截图和同文按钮数值分开保留。截图为施工员自测材料,仍需总工独立重拍复审与总管接续目检。
|
||||
|
||||
pytest 口径:本次只收集 `tests/test_workbench.py`(10项)与 `tests/test_playground_workbench_flowback.py`(8项),合计18项。原 HEL-731 的18项是工作台生命周期+来源回流的合计;HEL-773 单文件10项仅含前者。后端没有变更,不重复运行已通过的整套昂贵行为测试。lint、Svelte check(0错误0警告)、build、前端脚本和本次针对性浏览器验证日志交任务附件;构建的既有依赖导入/包大小提示保持如实记录。
|
||||
|
||||
未部署、未合并、无数据迁移或线上数据操作,无新增依赖。清理由 runner 的 finally 停止其启动的精确子进程组并移除隔离库;保留原始证据与校验清单供复审。
|
||||
|
||||
补充总管在施工中新增的稳定态要求:等待全部有限动画结束、字体和图片就绪后,比较弹层与搜索弹层背景实测为不透明,Escape关闭均恢复触发器焦点,无需改弹层。另复现保存成功提示确实挡住固定保存/确认按钮(1440档toast底876、footer顶844,两按钮中心命中toast)。修复仅在统一`themes.css`中定义项目底栏56px与提示间距token,ToastHost消费原24px默认值;有项目工作区时提示距底为56+24px,其他页面仍24px。底栏复用同一高度token,没有叠加新组件样式。四主题三尺寸补测提示与底栏不重叠、保存/确认命中、实际点击、提示消失后的长滚动底栏;最终24组页面重新拍摄,稳定弹层/提示证据另列stability/。变更后重新lint/check/build,不以旧截图冒充新状态。
|
||||
@@ -0,0 +1,11 @@
|
||||
HEL-776 接续 PR #10,按原 HTML 样本修正 B3 工作室/项目按钮的两项偏差;不改变业务逻辑。
|
||||
|
||||
- 共享按钮的 sm 规则将已有小号圆角参数传给次级变体,次级规则消费该尺寸参数后再回退到原普通圆角。B3 小号最终为 night/day/grade 5px、bluehour 8px;非 B3 未配置小号参数,仍回退到原次级圆角 8px。没有新增一套按钮样式。
|
||||
- 共享按钮明确主按钮禁用状态的参数入口。B3 边界设置 opacity=1,主题源提供 grayscale(.8) brightness(.6),day 单独提供 filter:none、背景 #e3ddd2 和文字 #8d877d。阴影继续由原通用禁用规则清除。非 B3 的参数回退保留 opacity=.45、原背景/文字与无滤镜;次级按钮禁用态未改。
|
||||
- 原生 disabled、aria-disabled 样式入口、确认条件、异步请求、防重复提交与保存逻辑全部沿用。没有变更后端或依赖。
|
||||
|
||||
自测由 e2e/run-hel776.py 启动一次性真实 API/数据库及本地假服务,e2e/hel776.cjs 核对四主题×三窗口×工作室/项目页的普通、小号次级按钮 normal/hover/active 最终数值;用真实未保存编辑产生主按钮禁用态,真实保存恢复可用,逐值比较原样本的 opacity/filter/background/color/shadow 等属性,并验证禁用无请求、pending 只提交一次、确认成功。搜索/比较稳定打开与焦点恢复,以及提示条不遮固定操作另作定向保护。样本按钮状态夹具只存在于样本浏览器页面,产品保留真实数据。
|
||||
|
||||
B1/B2 的 /create、/workflows、/tasks、/settings、/models 四主题控件对照精确旧版 app.css;补充非 B3 普通/sm 主次按钮 enabled/native disabled/aria-disabled 状态夹具,并在对照时移除本轮新增主按钮状态规则,避免旧 CSS 无法覆盖较高优先级规则导致无效验证。
|
||||
|
||||
本轮实跑 lint、Svelte check(0 errors / 0 warnings)、build、现有前端脚本及上述定向浏览器验证。复用 HEL-775 的工作台10项+来源回流8项后端18 passed记录,纯样式增量没有重复执行;没有复跑无关完整页面截图矩阵。原始日志、并排局部图、JSON/CSV、trace、清理记录通过任务附件交付,施工员自测不能代替独立复审与总管图片目检。未部署、未合并、未新增依赖。
|
||||
+5
-4
@@ -436,10 +436,11 @@ async function shot(p, name) {
|
||||
.filter({ hasText: "待配置测试工具" })
|
||||
.click();
|
||||
await p.waitForURL("**/workflows?manage=1&wf=" + pending.id);
|
||||
ok(
|
||||
"待配置导向真实管理和测试入口",
|
||||
await p.locator(".wf-test-btn").first().isVisible(),
|
||||
);
|
||||
// B2 replaced the old wf-test-btn class with the same real test action.
|
||||
// Wait for its data-driven detail panel; keep the original behavior assertion.
|
||||
const workflowTest = p.getByRole("button", { name: "试运行", exact: true });
|
||||
await workflowTest.waitFor();
|
||||
ok("待配置导向真实管理和测试入口", await workflowTest.isVisible());
|
||||
// Source project round-trip uses original API and explicit attachment.
|
||||
await api(
|
||||
"/api/workbench/projects/" + f.project + "/stages/assets",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Explain content-driven height differences without altering the frozen sample or product.
|
||||
// Original sample screenshots remain in the evidence pack. This second pass changes only
|
||||
// the two descriptive strings to the isolated fixture strings and measures their reflow.
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
const {chromium}=await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const out='e2e/shots/hel731';
|
||||
const measurements=JSON.parse(await fs.readFile(out+'/measurements.json','utf8'));
|
||||
const browser=await chromium.launch({args:['--no-sandbox']});
|
||||
const results=[];
|
||||
try{
|
||||
for(const [width,height] of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){
|
||||
const page=await browser.newPage({viewport:{width,height}});
|
||||
await page.goto(pathToFileURL(path.resolve('../attachments/夜片场-多主题交互样本.html')).href);
|
||||
await page.evaluate(t=>{document.documentElement.dataset.theme=t;document.documentElement.classList.add('reduce');document.querySelector('.hero .copy p').textContent='纸船带回一片树叶';document.querySelectorAll('#projShots .scard .bd p').forEach(e=>e.textContent='女孩沿着海岸走向灯塔,纸船随海风起伏。');},theme);
|
||||
for(const [name,ref,actual] of [['studio','.hero','.studio-hero'],['project','.scard','.storyboard-card']]){
|
||||
await page.evaluate(name=>document.querySelectorAll('.page').forEach(e=>e.classList.toggle('on',e.id==='p-'+name)),name);await page.waitForTimeout(350);
|
||||
const box=await page.locator(ref).first().boundingBox();const a=measurements.find(x=>x.width===width&&x.theme===theme&&x.selector===actual).actual;
|
||||
results.push({theme,width,page:name,sampleWithFixtureDescription:box,actual:a,deltaHeight:a.height-box.height});
|
||||
await page.screenshot({path:`${out}/${name}-${theme}-${width}-sample-description-normalized.png`});
|
||||
}await page.close();
|
||||
}
|
||||
await fs.writeFile(out+'/description-reflow.json',JSON.stringify(results,null,2));
|
||||
console.log('normalized reflow comparisons',results.length,'maximum height delta',Math.max(...results.map(r=>Math.abs(r.deltaHeight))));
|
||||
}finally{await browser.close()}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// B3: real local API, disposable database and fake ComfyUI only.
|
||||
const {chromium} = require(process.env.PLAYWRIGHT_MODULE_PATH || 'playwright');
|
||||
const fs=require('node:fs'),http=require('node:http'),assert=require('node:assert/strict');
|
||||
const API='http://127.0.0.1:8247',WEB='http://127.0.0.1:5173',out='e2e/shots/hel731';
|
||||
fs.mkdirSync(out,{recursive:true}); const checks=[],errors=[];
|
||||
const ok=(name,value)=>{assert(value,name);checks.push(name);console.log('PASS',name)};
|
||||
async function api(p,b,method){const r=await fetch(API+p,{method:method||(b?'POST':'GET'),headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});if(!r.ok)throw Error(p+' '+await r.text());return r.json()}
|
||||
async function wait(fn){for(let i=0;i<120;i++){const x=await fn();if(x)return x;await new Promise(r=>setTimeout(r,150))}throw Error('condition timeout')}
|
||||
const base=id=>'/api/workbench/projects/'+id;
|
||||
const fixtures={
|
||||
script:{text:'第一场:清晨的海岸,女孩把一只纸船放入海水。她等候着父亲的归来。第二场:纸船带回一片树叶,女孩望向海平面,知道旅程才刚刚开始。'},
|
||||
style:{directions:[{id:'s1',name:'晨光',description:'暖光',prompt:'warm light'},{id:'s2',name:'蓝调',description:'冷光',prompt:'blue hour'}]},
|
||||
storyboard:{shots:Array.from({length:12},(_,i)=>({id:'board-'+i,scene:'1',shot_size:'远景',camera:'固定',description:'女孩沿着海岸走向灯塔,纸船随海风起伏。',dialog:i%2?'你好':'',duration_sec:5}))},
|
||||
assets:{assets:[{id:'asset-a',kind:'character',name:'海边女孩',description:'女孩',prompt:'girl on coast'}]},
|
||||
shot:{shots:[{id:'shot-a',shot_no:'S1',description:'女孩走向灯塔',prompt:'girl walks toward lighthouse',duration_sec:4}]}
|
||||
};
|
||||
(async()=>{
|
||||
const b=await chromium.launch({executablePath:process.env.CHROMIUM_EXECUTABLE_PATH,args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
const sample=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../attachments/夜片场-多主题交互样本.html'))});await new Promise(r=>sample.listen(0,'127.0.0.1',r));
|
||||
const c=await b.newContext({viewport:{width:1440,height:900}});await c.tracing.start({screenshots:true,snapshots:true,sources:true});const p=await c.newPage();p.setDefaultTimeout(15000);p.on('pageerror',e=>errors.push(e.message));
|
||||
try{
|
||||
const f=JSON.parse(fs.readFileSync('e2e/shots/reference/fixtures.json'));
|
||||
const proj=await api('/api/projects',{title:'隔离验证 · 海岸来信',idea:'纸船带回一片树叶'}),other=await api('/api/projects',{title:'隔离验证 · 独立项目'});
|
||||
for(const [stage,content] of Object.entries(fixtures)){
|
||||
await api(base(proj.id)+'/stages/'+stage,{content},'PATCH');
|
||||
if(stage==='script'||stage==='style')await api(base(proj.id)+'/stages/'+stage+'/confirm',stage==='style'?{chosen_style_id:'s1'}:{});
|
||||
}
|
||||
// Verify real UI selection, compare modal and explicit confirmation.
|
||||
await p.goto(WEB+'/workbench/'+proj.id+'?stage=storyboard');await p.locator('.storyboard-card').first().waitFor();
|
||||
ok('分镜显示12张真实镜头卡',(await p.locator('.storyboard-card').count())===12);
|
||||
ok('固定确认首屏可见',await p.locator('[data-testid=stage-confirm-btn]').isVisible());
|
||||
await api(base(proj.id)+'/stages/storyboard/confirm',{});
|
||||
await api(base(proj.id)+'/stages/storyboard',{content:{shots:fixtures.storyboard.shots.map((x,i)=>i?x:{...x,description:'已编辑的第一个镜头'})}},'PATCH');
|
||||
await p.reload();await p.getByRole('button',{name:'对比已确认版',exact:true}).click();await p.locator('.project-compare').waitFor();await p.screenshot({path:out+'/compare-modal.png'});await p.keyboard.press('Escape');
|
||||
await p.locator('[data-testid=stage-restore-btn]').click();await wait(async()=>JSON.stringify((await api(base(proj.id))).stages.find(s=>s.stage_key==='storyboard').content)===JSON.stringify(fixtures.storyboard));ok('恢复真实确认快照',true);
|
||||
await api(base(proj.id)+'/stages/storyboard',{content:fixtures.storyboard},'PATCH');
|
||||
await api(base(proj.id)+'/stages/storyboard',{content:{shots:fixtures.storyboard.shots.map((x,i)=>i?x:{...x,description:'等待确认的新草稿'})}},'PATCH');
|
||||
await p.reload();await p.locator('[data-testid=stage-confirm-btn]').click();await wait(async()=>new URL(p.url()).searchParams.get('stage')==='assets');ok('仅显式确认后进入下一阶段',true);
|
||||
// Fixed footer saves the panel's edited draft and prevents stale confirmation.
|
||||
await p.goto(WEB+'/workbench/'+proj.id+'?stage=storyboard');await p.locator('.storyboard-card').first().waitFor();
|
||||
await p.getByRole('button',{name:'编辑',exact:true}).click();await p.locator('.shot-edit textarea').first().fill('通过固定底栏保存的新镜头描述');
|
||||
ok('编辑期间禁止确认旧内容',await p.locator('[data-testid=stage-confirm-btn]').isDisabled());
|
||||
await p.locator('[data-testid=stage-save-btn]').click();await wait(async()=>JSON.stringify(await api(base(proj.id))).includes('通过固定底栏保存的新镜头描述'));ok('固定底栏保存实际编辑草稿',true);
|
||||
for(const stage of ['script','style','storyboard','assets','shot']){
|
||||
await p.locator(`[data-stage=${stage}]`).click();await p.screenshot({path:`${out}/stage-${stage}.png`});
|
||||
ok('阶段切换 '+stage,new URL(p.url()).searchParams.get('stage')===stage);
|
||||
}
|
||||
await p.locator('[data-stage=storyboard]').click();await p.locator('[data-testid=stage-instruction]').fill('分镜专属要求');await p.locator('[data-stage=style]').click();ok('阶段要求不串阶段',await p.locator('[data-testid=stage-instruction]').inputValue()==='');await p.locator('[data-stage=storyboard]').click();ok('切回阶段保留要求',await p.locator('[data-testid=stage-instruction]').inputValue()==='分镜专属要求');await p.locator('[data-testid=stage-instruction]').fill('');
|
||||
await p.locator('[data-stage=storyboard]').click();await p.locator('.project-content').evaluate(e=>e.scrollTop=e.scrollHeight);await p.screenshot({path:out+'/long-scroll.png'});ok('长内容滚动时固定确认可用',await p.locator('[data-testid=stage-confirm-btn]').isVisible());
|
||||
// Project navigation stays scoped; edits from the old component cannot write into the new project.
|
||||
await p.goto(WEB+'/workbench/'+other.id+'?stage=script');await p.locator('[data-testid=workbench-page]').waitFor();ok('跨项目不带旧镜头',await p.locator('.storyboard-card').count()===0);
|
||||
// Script import uses the original import endpoint.
|
||||
await api(base(other.id)+'/stages/script/import',{text:fixtures.script.text});ok('剧本导入保留正式确认语义',(await api(base(other.id))).stages.find(s=>s.stage_key==='script').status==='confirmed');
|
||||
// Create a real 3D composition, render it in the existing viewport and save a screenshot.
|
||||
await p.goto(WEB+'/build-scene');await p.waitForTimeout(2000);await p.screenshot({path:out+'/scene-entry.png'});
|
||||
await p.locator('canvas').first().waitFor();
|
||||
await p.locator('.toolbar .capture').click();
|
||||
const scene = await wait(async () => (await api('/api/shots')).find(s => s.title));
|
||||
const cap = await wait(async () => (await api('/api/shots/'+scene.id+'/captures'))[0]);
|
||||
ok('真实三维视口截图写入隔离存储',!!cap.file_path);
|
||||
// Five-stage source -> real declared image slot -> fake generation -> explicit source attachment.
|
||||
const q=new URLSearchParams({kind:'video',wf:String(f.h3),from:'workbench',project:String(proj.id),stage:'shot',source_kind:'shot',source_id:'shot-a',label:'镜头 S1',prompt:fixtures.shot.shots[0].prompt});
|
||||
await p.goto(WEB+'/create?'+q);await p.locator('#desk-prompt').waitFor();
|
||||
await p.getByRole('button',{name:'管理素材',exact:false}).click();await p.getByRole('button',{name:'从三维搭景截取',exact:true}).first().click();await p.locator('.capture-choices button').first().click();
|
||||
await p.getByRole('button',{name:'从三维搭景截取',exact:true}).nth(1).click();await p.locator('.capture-choices button').first().click();
|
||||
await p.screenshot({path:out+'/capture-selected.png'});await p.getByRole('button',{name:'完成',exact:true}).click();
|
||||
fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:0,videos:true,unique_filename:true}));
|
||||
const before=Math.max(0,...(await api('/api/playground/jobs')).map(j=>j.id));await p.locator('.gen').click();
|
||||
const job=await wait(async()=>(await api('/api/playground/jobs')).find(j=>j.id>before&&j.status==='done'));
|
||||
ok('假服务生成保留来源镜头',JSON.stringify(job.payload).includes('shot-a'));
|
||||
await p.getByRole('button',{name:'存入项目',exact:true}).click();await wait(async()=>JSON.stringify(await api(base(proj.id))).includes(job.output_paths[0]));ok('截图参考生成结果回流来源项目',true);
|
||||
ok('回流不串项目',!JSON.stringify(await api(base(other.id))).includes(job.output_paths[0]));
|
||||
await p.goto(WEB+'/workbench/'+proj.id+'?stage=shot');await p.locator('.vshot-video').waitFor();
|
||||
ok('镜头阶段显示本阶段任务',await p.locator('[data-testid=workbench-jobs] .job-row').count()===1);
|
||||
ok('项目回流视频默认暂停',await p.locator('.vshot-video').evaluate(v=>v.paused));
|
||||
ok('项目视频手动播放',await p.locator('.vshot-video').evaluate(async v=>{await v.play();return !v.paused}));await p.screenshot({path:out+'/project-video-playing.png'});await p.locator('.vshot-video').evaluate(v=>v.pause());await p.screenshot({path:out+'/project-video-paused.png'});
|
||||
|
||||
await p.locator('[data-stage=storyboard]').click();ok('分镜阶段不混入镜头任务',await p.locator('[data-testid=workbench-jobs] .job-row').count()===0);
|
||||
// Text-only workflows must retain no image slots.
|
||||
await p.goto(WEB+'/create?kind=image&wf='+f.dual);await p.locator('#desk-prompt').waitFor();ok('文生图无伪造图片槽',await p.locator('.a-slot').count()===0);
|
||||
// Stable data state for visual evidence; these records live only in this disposable DB.
|
||||
await api(base(proj.id)+'/stages/storyboard',{content:fixtures.storyboard},'PATCH');
|
||||
const sp=await c.newPage();const measures=[];
|
||||
for(const [width,height] of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){
|
||||
await p.setViewportSize({width,height});await sp.setViewportSize({width,height});
|
||||
await p.evaluate(t=>{localStorage.setItem('aigc-theme',t)},theme);
|
||||
await sp.goto('http://127.0.0.1:'+sample.address().port);await sp.evaluate(t=>{document.documentElement.dataset.theme=t;document.documentElement.classList.add('reduce')},theme);
|
||||
for(const page of ['studio','project']){
|
||||
await p.goto(WEB+(page==='studio'?'/':'/workbench/'+proj.id+'?stage=storyboard'));
|
||||
await p.locator(page==='studio'?'.studio-hero':'.storyboard-card').first().waitFor();
|
||||
await sp.evaluate(v=>{document.querySelectorAll('.page').forEach(e=>e.classList.toggle('on',e.id==='p-'+v))},page);
|
||||
await sp.waitForTimeout(350);await p.waitForTimeout(100);
|
||||
if(page==='project')await p.getByRole('button',{name:'选择镜头 3',exact:true}).click();
|
||||
await p.screenshot({path:`${out}/${page}-${theme}-${width}-actual.png`});await sp.screenshot({path:`${out}/${page}-${theme}-${width}-sample.png`});
|
||||
const selectors=page==='studio'?[['.hero','.studio-hero'],['.copy','.hero-copy'],['.display','.studio-display'],['.studio-wrap','.home'],['.quick','.studio-quick']]:[['.stepper','.stepper'],['.proj-side','.project-side'],['.proj-foot','.project-footer'],['.scard','.storyboard-card'],['.ps-sec .input','.stage-input-wrap'],['#confirmStage','[data-testid=stage-confirm-btn]']];
|
||||
ok(`主题 ${theme} ${page} ${width}`, await p.evaluate(t=>document.documentElement.dataset.theme===t,theme));
|
||||
const measure=async(pg,sel)=>pg.locator(sel).first().evaluate(e=>{const b=e.getBoundingClientRect(),s=getComputedStyle(e);return {x:b.x,y:b.y,width:b.width,height:b.height,font:s.fontSize,weight:s.fontWeight,radius:s.borderRadius,color:s.color,background:s.backgroundColor}});
|
||||
for(const [ref,actual] of selectors)measures.push({page,theme,width,selector:actual,sample:await measure(sp,ref),actual:await measure(p,actual)});
|
||||
if(page==='project')ok(`固定确认 ${theme} ${width}`,await p.locator('[data-testid=stage-confirm-btn]').evaluate(e=>e.getBoundingClientRect().bottom<=innerHeight));
|
||||
}
|
||||
}
|
||||
await p.goto(WEB+'/');await p.locator('.studio-search').click();await p.screenshot({path:out+'/search-open.png'});await p.keyboard.press('Escape');await p.keyboard.press('Control+k');ok('工作室搜索快捷键保留',await p.getByRole('dialog').isVisible());await p.keyboard.press('Escape');
|
||||
await p.getByRole('button',{name:'主题',exact:true}).click();await p.locator('.choices .choice').filter({hasText:'日光棚'}).click();await p.waitForTimeout(600);await p.screenshot({path:out+'/theme-operation.png'});ok('主题操作生效',await p.evaluate(()=>document.documentElement.dataset.theme==='day'));await p.keyboard.press('Escape');
|
||||
await p.route('**/api/projects',route=>route.fulfill({json:[]}));await p.reload();await p.locator('.studio-hero').waitFor();await p.screenshot({path:out+'/empty-studio.png'});ok('空态不展示样本项目',!(await p.locator('.studio-hero').innerText()).includes('雾港来信'));await p.unroute('**/api/projects');
|
||||
fs.writeFileSync(out+'/measurements.json',JSON.stringify(measures,null,2));fs.writeFileSync(out+'/results.json',JSON.stringify({checks,errors},null,2));ok('无浏览器运行错误',errors.length===0);
|
||||
}finally{await c.tracing.stop({path:out+'/interaction-trace.zip'});await b.close();await new Promise(r=>sample.close(r));}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Supplemental requirements added to HEL-774: stable dialogs, focus return and unobstructed actions.
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright');
|
||||
const fs=require('node:fs'),assert=require('node:assert/strict'),http=require('node:http');
|
||||
const WEB='http://127.0.0.1:5173',API='http://127.0.0.1:8247',out='../evidence/stability';fs.mkdirSync(out,{recursive:true});
|
||||
const records=[],checks=[];const ok=(name,v)=>{assert(v,name);checks.push(name);console.log('PASS',name)};
|
||||
const api=async(path,body,method)=>{const r=await fetch(API+path,{method:method||(body?'POST':'GET'),headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(15000)});assert(r.ok);return r.json()};
|
||||
async function settled(p){await p.evaluate(async()=>{await document.fonts.ready;await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})));await Promise.all([...document.images].filter(i=>!i.complete).map(i=>new Promise(r=>{i.onload=r;i.onerror=r})))})}
|
||||
(async()=>{
|
||||
const b=await chromium.launch({executablePath:process.env.CHROMIUM_EXECUTABLE_PATH,args:['--no-sandbox','--disable-dev-shm-usage']});const c=await b.newContext();await c.tracing.start({screenshots:true,snapshots:true});const p=await c.newPage();p.setDefaultTimeout(15000);
|
||||
const sample=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../attachments/夜片场-多主题交互样本.html'))});await new Promise(r=>sample.listen(0,'127.0.0.1',r));const sp=await c.newPage();
|
||||
try{
|
||||
const proj=(await api('/api/projects')).find(x=>x.title==='隔离验证 · 海岸来信');const path='/api/workbench/projects/'+proj.id+'/stages/storyboard';
|
||||
await api(path+'/confirm',{});const stage=(await api('/api/workbench/projects/'+proj.id)).stages.find(s=>s.stage_key==='storyboard');await api(path,{content:{...stage.content,shots:stage.content.shots.map((s,i)=>i?s:{...s,description:'稳定态对比用实际草稿'})}},'PATCH');
|
||||
const sizes=(process.env.HEL774_BEFORE==='1'||process.env.HEL774_QUICK==='1')?[[1440,900]]:[[1440,900],[1366,768],[1280,720]],themes=(process.env.HEL774_BEFORE==='1'||process.env.HEL774_QUICK==='1')?['night']:['night','day','grade','bluehour'];
|
||||
for(const [width,height] of sizes)for(const theme of themes){
|
||||
const name=theme+'-'+width;await p.setViewportSize({width,height});await sp.setViewportSize({width,height});await p.goto(WEB);await p.evaluate(t=>localStorage.setItem('aigc-theme',t),theme);await p.goto(WEB+'/workbench/'+proj.id+'?stage=storyboard');await p.locator('.storyboard-card').first().waitFor();await settled(p);
|
||||
// Modal: stable background and finished animation; close by Escape and check exact invoker.
|
||||
const compare=p.getByRole('button',{name:'对比已确认版',exact:true});await compare.click();await p.getByRole('dialog').waitFor();await settled(p);
|
||||
let style=await p.locator('.modal-panel').evaluate(e=>{const s=getComputedStyle(e);return {background:s.backgroundColor,opacity:s.opacity,transform:s.transform,float:getComputedStyle(document.documentElement).getPropertyValue('--float-bg')}});records.push({name,kind:'compare',style});await p.screenshot({path:`${out}/${name}-compare-stable.png`});ok(name+' compare opaque after animation',style.opacity==='1'&&!['rgba(0, 0, 0, 0)','transparent'].includes(style.background));await p.keyboard.press('Escape');await p.getByRole('dialog').waitFor({state:'detached'});ok(name+' compare returns focus',await compare.evaluate(e=>document.activeElement===e));
|
||||
// Produce a real save toast, then hit-test both primary actions while it is visible.
|
||||
await p.getByRole('button',{name:'编辑',exact:true}).click();await p.locator('.shot-edit textarea').first().fill('稳定保存 '+name);await p.locator('[data-testid=stage-save-btn]').click();await p.locator('.toast.success').waitFor();await settled(p);
|
||||
const hit=await p.locator('.project-footer').evaluate(e=>{const footer=e.getBoundingClientRect();return {footerTop:footer.top,toast:[...document.querySelectorAll('.toast')].map(t=>{const r=t.getBoundingClientRect();return {top:r.top,bottom:r.bottom,left:r.left,right:r.right}}),buttons:[...e.querySelectorAll('button')].map(b=>{const r=b.getBoundingClientRect(),x=r.left+r.width/2,y=r.top+r.height/2;const top=document.elementFromPoint(x,y);return {text:b.textContent.trim(),disabled:b.disabled,centerHit:top===b||b.contains(top),topElement:top?.className}})}});records.push({name,kind:'toast',hit});await p.screenshot({path:`${out}/${name}-toast-visible.png`});
|
||||
if(process.env.HEL774_BEFORE!=='1'){
|
||||
ok(name+' toast above fixed footer',hit.toast.every(t=>t.bottom<=hit.footerTop));ok(name+' save/confirm centers hit actual buttons',hit.buttons.every(b=>b.centerHit));
|
||||
// Actual mouse save with toast present proves the overlay does not intercept it.
|
||||
await p.locator('[data-testid=stage-save-btn]').click();ok(name+' save clickable with toast',true);
|
||||
}
|
||||
await p.waitForTimeout(300);while(await p.locator('.toast .dismiss').count())await p.locator('.toast .dismiss').first().click();await p.locator('.toast').waitFor({state:'detached'});await settled(p);
|
||||
await p.locator('.project-content').evaluate(e=>e.scrollTop=e.scrollHeight);await p.screenshot({path:`${out}/${name}-long-scroll-clear-footer.png`});ok(name+' footer visible after toast clears',await p.locator('[data-testid=stage-confirm-btn]').isVisible());
|
||||
await p.goto(WEB);const search=p.locator('.studio-search');await search.click();await p.getByRole('dialog').waitFor();await settled(p);style=await p.locator('.modal-panel').evaluate(e=>{const s=getComputedStyle(e);return {background:s.backgroundColor,opacity:s.opacity,transform:s.transform}});records.push({name,kind:'search',style});await p.screenshot({path:`${out}/${name}-search-stable.png`});ok(name+' search opaque after animation',style.opacity==='1'&&!['rgba(0, 0, 0, 0)','transparent'].includes(style.background));await p.keyboard.press('Escape');await p.getByRole('dialog').waitFor({state:'detached'});ok(name+' search returns focus',await search.evaluate(e=>document.activeElement===e));
|
||||
// Re-shoot the complete unmodified sample/app pairs on this exact final product revision.
|
||||
if(process.env.HEL774_BEFORE!=='1'){
|
||||
await sp.goto('http://127.0.0.1:'+sample.address().port);await sp.evaluate(t=>{document.documentElement.dataset.theme=t;document.documentElement.classList.add('reduce')},theme);
|
||||
for(const page of ['studio','project']){await p.goto(WEB+(page==='studio'?'/':'/workbench/'+proj.id+'?stage=storyboard'));await p.locator(page==='studio'?'.studio-hero':'.storyboard-card').first().waitFor();await sp.evaluate(page=>document.querySelectorAll('.page').forEach(e=>e.classList.toggle('on',e.id==='p-'+page)),page);await settled(p);await settled(sp);await p.mouse.move(0,0);await p.screenshot({path:`../evidence/images/${page}-${theme}-${width}-actual.png`});await sp.screenshot({path:`../evidence/images/${page}-${theme}-${width}-sample.png`})}
|
||||
}
|
||||
}
|
||||
if(process.env.HEL774_BEFORE!=='1'){
|
||||
await p.goto(WEB+'/workbench/'+proj.id+'?stage=storyboard');await p.locator('.storyboard-card').first().waitFor();
|
||||
await p.locator('[data-testid=stage-save-btn]').click();await p.locator('.toast.success').waitFor();await settled(p);await p.screenshot({path:out+'/confirm-click-with-toast.png'});
|
||||
await Promise.all([p.waitForURL('**?stage=assets'),p.locator('[data-testid=stage-confirm-btn]').click()]);
|
||||
ok('actual confirm clickable with toast and advances only explicitly',(await api('/api/workbench/projects/'+proj.id)).stages.find(s=>s.stage_key==='storyboard').status==='confirmed');await p.screenshot({path:out+'/confirm-click-result.png'});
|
||||
}
|
||||
|
||||
}finally{fs.writeFileSync(out+(process.env.HEL774_BEFORE==='1'?'/before.json':process.env.HEL774_QUICK==='1'?'/quick-results.json':'/results.json'),JSON.stringify({records,checks},null,2));await c.tracing.stop({path:out+(process.env.HEL774_QUICK==='1'?'/quick-trace.zip':'/trace.zip')});await b.close();await new Promise(r=>sample.close(r))}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -0,0 +1,93 @@
|
||||
// HEL-774 focused repair verification. Uses only the runner's disposable real API records.
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH || 'playwright');
|
||||
const fs=require('node:fs'),http=require('node:http'),assert=require('node:assert/strict');
|
||||
const out='../evidence', WEB='http://127.0.0.1:5173', API='http://127.0.0.1:8247';
|
||||
const resume=process.env.HEL774_ONLY_OPS==='1';
|
||||
const previous=resume?JSON.parse(fs.readFileSync('../evidence/results.json')):{checks:[]};
|
||||
const rows=resume?JSON.parse(fs.readFileSync('../evidence/measurements.json')):[],checks=previous.checks,errors=[],tags=resume?JSON.parse(fs.readFileSync('../evidence/tags.json')):[];
|
||||
const ok=(name,value)=>{assert(value,name);checks.push(name);console.log('PASS',name)};
|
||||
const api=async(path,body,method)=>{const r=await fetch(API+path,{method:method||(body?'POST':'GET'),headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(15000)});assert(r.ok,await r.clone().text());return r.json()};
|
||||
const fingerprint=loc=>loc.evaluate(e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return Object.fromEntries(['backgroundColor','borderTopColor','borderTopWidth','paddingLeft','paddingRight','fontSize','fontWeight','borderRadius','transform','opacity'].map(k=>[k,s[k]]).concat([['width',r.width],['height',r.height]]))});
|
||||
const wait=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
(async()=>{
|
||||
fs.mkdirSync(out+'/images',{recursive:true});fs.mkdirSync(out+'/closeups',{recursive:true});
|
||||
const server=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../attachments/夜片场-多主题交互样本.html'))});
|
||||
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_EXECUTABLE_PATH,args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
||||
const ctx=await browser.newContext();await ctx.tracing.start({screenshots:true,snapshots:true,sources:true});
|
||||
const p=await ctx.newPage(),sp=await ctx.newPage();p.on('pageerror',e=>errors.push(e.message));p.setDefaultTimeout(15000);
|
||||
try{
|
||||
const projects=await api('/api/projects'),proj=projects.find(x=>x.title==='隔离验证 · 海岸来信');assert(proj);
|
||||
const projectPath='/workbench/'+proj.id+'?stage=storyboard';
|
||||
if(!resume)for(const [width,height] of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){
|
||||
await p.setViewportSize({width,height});await sp.setViewportSize({width,height});
|
||||
await sp.goto('http://127.0.0.1:'+server.address().port);await sp.evaluate(t=>{document.documentElement.dataset.theme=t;document.documentElement.classList.add('reduce')},theme);
|
||||
await p.goto(WEB);await p.evaluate(t=>{localStorage.setItem('aigc-theme',t)},theme);
|
||||
for(const page of ['studio','project']){
|
||||
const name=`${page}-${theme}-${width}`;
|
||||
await p.goto(WEB+(page==='studio'?'/':projectPath));await p.locator(page==='studio'?'.confirm-item':'.storyboard-card').first().waitFor();
|
||||
await sp.evaluate(v=>document.querySelectorAll('.page').forEach(e=>e.classList.toggle('on',e.id==='p-'+v)),page);
|
||||
await p.mouse.move(0,0);await sp.mouse.move(0,0);await wait(400);
|
||||
await p.screenshot({path:`${out}/images/${name}-actual.png`});await sp.screenshot({path:`${out}/images/${name}-sample.png`});
|
||||
const scope=page==='studio'?'.home':'.project-workspace';
|
||||
const selectors=await p.locator(scope+' .a-btn.secondary').evaluateAll(els=>els.map((e,i)=>({i,text:e.textContent.trim(),classes:e.className,disabled:!!e.disabled,tag:e.tagName})));
|
||||
ok(name+' secondary controls present',selectors.length>0);
|
||||
for(const item of selectors){
|
||||
const al=p.locator(scope+' .a-btn.secondary').nth(item.i);
|
||||
// Same text on a sample control, preserving the original sample's class rules.
|
||||
const id='hel774-reference';await sp.evaluate(({id,text,small})=>{document.getElementById(id)?.remove();const e=document.createElement('button');e.id=id;e.className='btn'+(small?' sm':'');e.textContent=text;e.style.position='fixed';e.style.left='100px';e.style.top='100px';document.querySelector('.page.on').append(e)}, {id,text:item.text,small:item.classes.split(' ').includes('sm')});
|
||||
const sl=sp.locator('#'+id);
|
||||
// Every actual secondary instance, including small controls. Disabled anchors are ARIA fixtures.
|
||||
for(const state of ['normal','hover','active','disabled']){
|
||||
await al.evaluate(e=>{e.removeAttribute('disabled');e.removeAttribute('aria-disabled')});await sl.evaluate(e=>e.disabled=false);
|
||||
await p.mouse.move(0,0);await sp.mouse.move(0,0);
|
||||
if(state==='disabled'){await al.evaluate(e=>e.tagName==='BUTTON'?e.disabled=true:e.setAttribute('aria-disabled','true'));await sl.evaluate(e=>e.disabled=true)}
|
||||
if(state==='hover'||state==='active'){await al.hover();await sl.hover()}
|
||||
if(state==='active'){await p.mouse.down();await sp.mouse.down()}
|
||||
await wait(220);const actual=await fingerprint(al),sample=await fingerprint(sl);
|
||||
rows.push({page,theme,width,text:item.text,classes:item.classes,state,syntheticDisabled:state==='disabled',sample,actual});
|
||||
const props=['backgroundColor','borderTopColor','borderTopWidth','paddingLeft','paddingRight','fontSize','fontWeight'];
|
||||
if(state==='active')props.push('transform');
|
||||
for(const key of props)assert.equal(actual[key],sample[key],`${name} ${item.text} ${state} ${key}`);
|
||||
if(!item.classes.includes('b3-grow'))assert(Math.abs(actual.width-sample.width)<0.05,`${name} ${item.text} ${state} width ${actual.width} vs ${sample.width}`);
|
||||
if(item.i===0 && (state==='normal'||state==='hover'||state==='disabled')){await al.screenshot({path:`${out}/closeups/${name}-${state}-actual.png`});await sl.screenshot({path:`${out}/closeups/${name}-${state}-sample.png`})}
|
||||
if(state==='active'){await p.mouse.move(0,0);await sp.mouse.move(0,0);await p.mouse.up();await sp.mouse.up()}
|
||||
}
|
||||
await al.evaluate((e,disabled)=>{e.removeAttribute('aria-disabled');if(disabled)e.setAttribute('disabled','');else e.removeAttribute('disabled')},item.disabled);await sl.evaluate(e=>e.remove());
|
||||
}
|
||||
await p.mouse.move(0,0);await sp.mouse.move(0,0);
|
||||
if(page==='studio'){
|
||||
const a=await fingerprint(p.locator('.confirm-item').first()),s=await fingerprint(sp.locator('.confirm-item').first());rows.push({page,theme,width,selector:'.confirm-item',actual:a,sample:s});ok(name+' confirmation font inherits sample 13px',a.fontSize===s.fontSize&&a.fontSize==='13px');
|
||||
await p.locator('.confirm-item').first().screenshot({path:`${out}/closeups/${name}-confirm-actual.png`});await sp.locator('.confirm-item').first().screenshot({path:`${out}/closeups/${name}-confirm-sample.png`});
|
||||
}
|
||||
const tagRows=await p.locator(scope+' .a-tag').evaluateAll(els=>els.map(e=>{const before=e.getBoundingClientRect();const saved=e.style.columnGap;e.style.columnGap='0px';const after=e.getBoundingClientRect();e.style.columnGap=saved;return {text:e.textContent.trim(),elements:e.children.length,childNodes:[...e.childNodes].filter(n=>n.nodeType!==3||n.textContent.trim()).map(n=>n.nodeType),before:before.width,after:after.width,gap:getComputedStyle(e).columnGap}}));tags.push({page,theme,width,tags:tagRows});ok(name+' single-content tags unaffected by gap',tagRows.every(t=>t.elements===0&&t.before===t.after));
|
||||
// Primary and small variants keep their own existing padding.
|
||||
ok(name+' primary padding preserved',await p.locator(scope+' .a-btn.primary').first().evaluate(e=>getComputedStyle(e).paddingLeft==='12px'));
|
||||
}
|
||||
// Keep actual empty state evidence, without manufacturing demo projects or media.
|
||||
await p.route('**/api/projects',r=>r.fulfill({json:[]}));await p.goto(WEB);await p.locator('.studio-hero').waitFor();await wait(100);await p.screenshot({path:`${out}/images/empty-${theme}-${width}.png`});ok(`empty ${theme} ${width}`,await p.locator('.studio-display').innerText().then(t=>t.includes('从一个想法开始')));await p.unroute('**/api/projects');
|
||||
}
|
||||
// Meaningful operation checks for the affected buttons, with actual API persistence.
|
||||
await p.goto(WEB);await p.locator('.studio-hero .secondary').waitFor();const heroHref=await p.locator('.studio-hero .secondary').getAttribute('href');await Promise.all([p.waitForURL(WEB+heroHref),p.locator('.studio-hero .secondary').click()]);ok('studio secondary opens real project stage',new URL(p.url()).pathname+new URL(p.url()).search===heroHref);
|
||||
await p.goto(WEB+projectPath);await p.locator('.storyboard-card').first().waitFor();await p.getByRole('button',{name:'编辑',exact:true}).click();await p.locator('.shot-edit textarea').first().fill('HEL774 次级保存按钮实际操作');ok('editing prevents stale confirmation',await p.locator('[data-testid=stage-confirm-btn]').isDisabled());await p.route('**/stages/storyboard',async route=>{if(route.request().method()==='PATCH')await wait(1000);await route.continue()});await p.locator('[data-testid=stage-save-btn]').click();await p.locator('[data-testid=stage-save-btn]:disabled').waitFor();ok('save secondary disabled while request pending',await p.locator('[data-testid=stage-save-btn]').isDisabled());await p.screenshot({path:out+'/images/operation-save-pending.png'});for(let n=0;n<50;n++){if(JSON.stringify(await api('/api/workbench/projects/'+proj.id)).includes('HEL774 次级保存按钮实际操作'))break;await wait(100)}ok('secondary saves actual edited draft',JSON.stringify(await api('/api/workbench/projects/'+proj.id)).includes('HEL774 次级保存按钮实际操作'));await p.screenshot({path:out+'/images/operation-save.png'});await p.unroute('**/stages/storyboard');
|
||||
await p.goto(WEB);await Promise.all([p.waitForURL('**/workbench/**'),p.locator('.confirm-item .secondary').first().click()]);ok('pending secondary navigates to draft',p.url().includes('/workbench/'));
|
||||
await p.goto(WEB);await p.locator('.studio-search').click();ok('search remains functional',await p.getByRole('dialog').isVisible());await p.keyboard.press('Escape');await p.keyboard.press('Control+k');ok('search shortcut preserved',await p.getByRole('dialog').isVisible());await p.keyboard.press('Escape');
|
||||
// B1/B2: compare live shared controls with exact previous shared declarations applied via CSSOM.
|
||||
const protections=[];
|
||||
for(const route of ['/create','/workflows','/tasks','/settings']){
|
||||
await p.goto(WEB+route);await wait(800);
|
||||
ok(route+' sidebar retained',await p.locator('aside.sidebar').isVisible());
|
||||
for(const theme of ['night','day','grade','bluehour']){
|
||||
await p.mouse.move(0,0);await p.evaluate(t=>document.documentElement.dataset.theme=t,theme);await wait(600);
|
||||
const buttons=p.locator('.a-btn:visible');const count=await buttons.count();
|
||||
const baseline=await buttons.evaluateAll(es=>es.map(e=>{const s=getComputedStyle(e),b=e.getBoundingClientRect();return {text:e.textContent.trim(),bg:s.backgroundColor,border:s.borderTopColor,pad:s.paddingLeft,width:b.width,height:b.height,font:s.fontSize}}));
|
||||
await p.evaluate(()=>{window.hel774Rules=[];for(const sheet of document.styleSheets){let rules;try{rules=sheet.cssRules}catch{continue}for(const r of rules){if(!r.selectorText)continue;const sel=r.selectorText;if(sel==='.a-btn'){window.hel774Rules.push([r,r.style.cssText]);r.style.padding='0 var(--sp-6)'}if(sel==='.a-btn.secondary'){window.hel774Rules.push([r,r.style.cssText]);r.style.borderColor='var(--line)';r.style.removeProperty('--button-padding');r.style.removeProperty('--button-active-transform')}}}});
|
||||
const previous=await buttons.evaluateAll(es=>es.map(e=>{const s=getComputedStyle(e),b=e.getBoundingClientRect();return {text:e.textContent.trim(),bg:s.backgroundColor,border:s.borderTopColor,pad:s.paddingLeft,width:b.width,height:b.height,font:s.fontSize}}));
|
||||
await p.evaluate(()=>window.hel774Rules.forEach(([r,css])=>r.style.cssText=css));assert.deepEqual(baseline,previous,route+' default controls changed');protections.push({route,theme,count,baseline,previous});
|
||||
await p.screenshot({path:`${out}/images/protect-${route.slice(1)}-${theme}.png`});
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(out+'/protections.json',JSON.stringify(protections,null,2));
|
||||
ok('no browser errors',errors.length===0);
|
||||
}finally{fs.writeFileSync(out+'/measurements.json',JSON.stringify(rows,null,2));fs.writeFileSync(out+'/tags.json',JSON.stringify(tags,null,2));fs.writeFileSync(out+'/results.json',JSON.stringify({checks,errors},null,2));await ctx.tracing.stop({path:out+'/interaction-trace.zip'});await browser.close();await new Promise(r=>server.close(r))}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -0,0 +1,93 @@
|
||||
// HEL-776: real isolated pages; sample controls are state fixtures, never product data.
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright');
|
||||
const fs=require('node:fs'),http=require('node:http'),assert=require('node:assert/strict'),cp=require('node:child_process');
|
||||
const out='../evidence',WEB='http://127.0.0.1:5173',API='http://127.0.0.1:8247';
|
||||
const rows=[],checks=[],errors=[],protections=[];
|
||||
const ok=(name,v)=>{assert(v,name);checks.push(name);console.log('PASS',name)};
|
||||
const wait=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
const api=async(path,body,method)=>{const r=await fetch(API+path,{method:method||(body?'POST':'GET'),headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});assert(r.ok,await r.clone().text());return r.json()};
|
||||
const style=loc=>loc.evaluate(e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return Object.fromEntries(['opacity','filter','backgroundColor','backgroundImage','color','boxShadow','borderRadius','paddingLeft','paddingRight','fontSize','fontWeight','borderTopColor','borderTopWidth','transform'].map(k=>[k,s[k]]).concat([['height',r.height]]))});
|
||||
async function settled(p){await p.evaluate(async()=>{await document.fonts.ready;await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))});await wait(180)}
|
||||
(async()=>{
|
||||
fs.mkdirSync(out+'/closeups',{recursive:true});fs.mkdirSync(out+'/operations',{recursive:true});
|
||||
const server=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../materials/夜片场-多主题交互样本.html'))});await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
||||
const b=await chromium.launch({args:['--no-sandbox','--disable-dev-shm-usage']});const c=await b.newContext();await c.tracing.start({screenshots:process.env.HEL776_PROTECT_ONLY!=='1',snapshots:true,sources:true});
|
||||
const p=await c.newPage(),sp=await c.newPage();p.setDefaultTimeout(15000);p.on('pageerror',e=>errors.push(e.message));
|
||||
const proj=(await api('/api/projects')).find(x=>x.title==='隔离验证 · 海岸来信'),path='/api/workbench/projects/'+proj.id+'/stages/storyboard',url=WEB+'/workbench/'+proj.id+'?stage=storyboard';
|
||||
try{
|
||||
if(process.env.HEL776_PROTECT_ONLY!=='1'){
|
||||
for(const [width,height] of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){
|
||||
await p.setViewportSize({width,height});await sp.setViewportSize({width,height});await sp.goto('http://127.0.0.1:'+server.address().port);await sp.evaluate(t=>document.documentElement.dataset.theme=t,theme);
|
||||
await p.goto(WEB);await p.evaluate(t=>localStorage.setItem('aigc-theme',t),theme);
|
||||
for(const page of ['studio','project']){
|
||||
await p.goto(page==='studio'?WEB:url);await p.locator(page==='studio'?'.studio-hero':'.storyboard-card').first().waitFor();await settled(p);
|
||||
const scope=page==='studio'?'.home':'.project-workspace';
|
||||
const controls=p.locator(scope+' .a-btn.secondary');
|
||||
for(let i=0;i<await controls.count();i++){
|
||||
const al=controls.nth(i),item=await al.evaluate(e=>({text:e.textContent.trim(),small:e.classList.contains('sm'),disabled:e.disabled,aria:e.getAttribute('aria-disabled')}));
|
||||
await sp.evaluate(item=>{document.querySelector('#fixture')?.remove();const e=document.createElement('button');e.id='fixture';e.className='btn'+(item.small?' sm':'');e.textContent=item.text;Object.assign(e.style,{position:'fixed',left:'100px',top:'100px'});document.body.append(e)},item);
|
||||
const sl=sp.locator('#fixture');
|
||||
for(const state of ['normal','hover','active']){
|
||||
await al.evaluate(e=>{e.removeAttribute('disabled');e.removeAttribute('aria-disabled')});await p.mouse.move(0,0);await sp.mouse.move(0,0);
|
||||
if(state!=='normal'){await al.hover();await sl.hover()}if(state==='active'){await p.mouse.down();await sp.mouse.down()}
|
||||
await wait(180);const actual=await style(al),sample=await style(sl);rows.push({page,theme,width,text:item.text,small:item.small,state,actual,sample});
|
||||
for(const k of ['backgroundColor','borderTopColor','borderTopWidth','borderRadius','paddingLeft','paddingRight','fontSize','fontWeight','height',...(state==='active'?['transform']:[])])assert.equal(actual[k],sample[k],`${page}/${theme}/${width}/${item.text}/${state}/${k}`);
|
||||
if(state==='active'){await p.mouse.move(0,0);await sp.mouse.move(0,0);await p.mouse.up();await sp.mouse.up()}
|
||||
}
|
||||
await al.evaluate((e,item)=>{if(item.disabled)e.setAttribute('disabled','');if(item.aria!==null)e.setAttribute('aria-disabled',item.aria)},item);
|
||||
}
|
||||
ok(`${page} ${theme} ${width}: every normal/small secondary normal/hover/active equals sample`,true);
|
||||
}
|
||||
// Native disabled confirmation caused by an actual unsaved edit, then actual save restores it.
|
||||
await p.goto(url);await p.locator('.storyboard-card').first().waitFor();await p.getByRole('button',{name:'编辑',exact:true}).click();await p.locator('.shot-edit textarea').first().fill('HEL776 '+theme+' '+width);
|
||||
const confirm=p.locator('[data-testid=stage-confirm-btn]');ok(`${theme} ${width} unsaved draft disables confirmation`,await confirm.isDisabled());
|
||||
let confirms=0;const onRequest=req=>{if(req.method()==='POST'&&req.url().endsWith('/stages/storyboard/confirm'))confirms++};p.on('request',onRequest);
|
||||
await sp.evaluate(text=>{document.querySelector('#fixture')?.remove();const e=document.createElement('button');e.id='fixture';e.className='btn primary';e.textContent=text;e.disabled=true;Object.assign(e.style,{position:'fixed',left:'100px',top:'100px'});document.body.append(e)},await confirm.innerText());
|
||||
for(const state of ['disabled','enabled']){
|
||||
if(state==='enabled'){
|
||||
await p.locator('[data-testid=stage-save-btn]').click();await p.locator('.toast.success').waitFor();await p.waitForFunction(()=>!document.querySelector('[data-testid=stage-confirm-btn]').disabled);await sp.locator('#fixture').evaluate(e=>e.disabled=false);
|
||||
ok(`${theme} ${width} save actually persists draft`,JSON.stringify(await api('/api/workbench/projects/'+proj.id)).includes('HEL776 '+theme+' '+width));
|
||||
}
|
||||
await p.mouse.move(0,0);await sp.mouse.move(0,0);await settled(p);const actual=await style(confirm),sample=await style(sp.locator('#fixture'));rows.push({page:'project',theme,width,kind:'primary',state,actual,sample});
|
||||
for(const k of ['opacity','filter','backgroundColor','backgroundImage','color','boxShadow','borderRadius','height','paddingLeft','fontSize'])assert.equal(actual[k],sample[k],`${theme}/${width}/primary/${state}/${k}`);
|
||||
if(width===1440){await confirm.screenshot({path:`${out}/closeups/${theme}-${state}-actual.png`});await sp.locator('#fixture').screenshot({path:`${out}/closeups/${theme}-${state}-sample.png`});await p.screenshot({path:`${out}/operations/${theme}-${state}.png`})}
|
||||
if(state==='disabled'){
|
||||
const r=await confirm.boundingBox();await p.mouse.click(r.x+r.width/2,r.y+r.height/2);await confirm.evaluate(e=>e.click());await wait(150);ok(`${theme} ${width} disabled native click sends zero requests`,confirms===0);
|
||||
}
|
||||
}
|
||||
const hits=await p.locator('.project-footer').evaluate(e=>{const top=e.getBoundingClientRect().top;return {top,toasts:[...document.querySelectorAll('.toast')].map(t=>t.getBoundingClientRect().bottom),buttons:[...e.querySelectorAll('button')].map(b=>{const r=b.getBoundingClientRect(),hit=document.elementFromPoint(r.x+r.width/2,r.y+r.height/2);return b===hit||b.contains(hit)})}});
|
||||
ok(`${theme} ${width} toast avoids footer and actions remain hittable`,hits.buttons.every(Boolean)&&hits.toasts.every(bottom=>bottom<=hits.top));
|
||||
// Bound pending confirmation to verify native duplicate prevention; release to real API.
|
||||
let release;const gate=new Promise(r=>release=r);await p.route('**/stages/storyboard/confirm',async r=>{await gate;await r.continue()});
|
||||
const response=p.waitForResponse(r=>r.url().endsWith('/stages/storyboard/confirm')&&r.request().method()==='POST');await confirm.click();await p.locator('[data-testid=stage-confirm-btn]:disabled').waitFor();await confirm.evaluate(e=>e.click());ok(`${theme} ${width} pending confirmation sends one request`,confirms===1);release();assert((await response).ok());await p.waitForURL('**?stage=assets');p.off('request',onRequest);await p.unroute('**/stages/storyboard/confirm');
|
||||
ok(`${theme} ${width} confirmation succeeds after save`,(await api('/api/workbench/projects/'+proj.id)).stages.find(s=>s.stage_key==='storyboard').status==='confirmed');
|
||||
// Restore awaiting_confirm through the real draft API, never through a status fixture.
|
||||
const data=(await api('/api/workbench/projects/'+proj.id)).stages.find(s=>s.stage_key==='storyboard');await api(path,{content:{...data.content,shots:data.content.shots.map((s,i)=>i?s:{...s,description:s.description+' · next'})}},'PATCH');
|
||||
}
|
||||
// Search + comparison focus and stable panel regression, all themes at 1440.
|
||||
await p.setViewportSize({width:1440,height:900});
|
||||
for(const theme of ['night','day','grade','bluehour']){
|
||||
await p.goto(WEB);await p.evaluate(t=>localStorage.setItem('aigc-theme',t),theme);await p.reload();const trigger=p.locator('.studio-search');await trigger.click();await p.getByRole('dialog').waitFor();await settled(p);ok(theme+' search stable opacity',await p.locator('.modal-panel').evaluate(e=>getComputedStyle(e).opacity==='1'));await p.keyboard.press('Escape');await p.getByRole('dialog').waitFor({state:'detached'});ok(theme+' search focus restored',await trigger.evaluate(e=>e===document.activeElement));
|
||||
await p.goto(url);const compare=p.getByRole('button',{name:'对比已确认版',exact:true});await compare.click();await p.getByRole('dialog').waitFor();await settled(p);ok(theme+' compare stable opacity',await p.locator('.modal-panel').evaluate(e=>getComputedStyle(e).opacity==='1'));await p.keyboard.press('Escape');await p.getByRole('dialog').waitFor({state:'detached'});ok(theme+' compare focus restored',await compare.evaluate(e=>e===document.activeElement));
|
||||
}
|
||||
}
|
||||
// Compare each live B1/B2 control with the exact pre-repair shared CSS in the same DOM.
|
||||
const old=cp.execFileSync('git',['show','930cca6:calliope-web/src/routes/app.css'],{encoding:'utf8'});
|
||||
for(const route of ['/create','/workflows','/tasks','/settings','/models'])for(const theme of ['night','day','grade','bluehour']){
|
||||
await p.goto(WEB+route);await p.locator('aside.sidebar').waitFor();await p.evaluate(t=>document.documentElement.dataset.theme=t,theme);await wait(600);await p.mouse.move(0,0);ok(route+' '+theme+' sidebar present',await p.locator('aside.sidebar').isVisible());
|
||||
const read=()=>p.locator('.a-btn').evaluateAll(es=>es.map(e=>{const s=getComputedStyle(e);return {text:e.textContent.trim(),classes:e.className,style:Object.fromEntries(['opacity','filter','backgroundColor','backgroundImage','color','boxShadow','borderRadius','paddingLeft','height','fontSize'].map(k=>[k,s[k]]))}}));
|
||||
await p.evaluate(()=>{const host=document.createElement('div');host.id='hel776-default-fixtures';for(const classes of ['primary','primary sm','secondary','secondary sm'])for(const state of ['enabled','disabled','aria-disabled']){const b=document.createElement('button');b.className='a-btn '+classes;b.textContent=classes+' '+state;if(state==='disabled')b.disabled=true;if(state==='aria-disabled')b.setAttribute('aria-disabled','true');host.append(b)}document.body.append(host)});
|
||||
const actual=await read();await p.evaluate(()=>{window.hel776StateRules=[];for(const sheet of document.styleSheets){let rules;try{rules=sheet.cssRules}catch{continue}for(const r of rules)if(r.selectorText?.includes('.a-btn.primary:disabled')){window.hel776StateRules.push([r,r.style.cssText]);r.style.cssText=''}}});const oldSheet=await p.addStyleTag({content:old});await wait(200);const previous=await read();assert.deepEqual(actual,previous,route+' '+theme+' default controls');await oldSheet.evaluate(e=>e.remove());await p.evaluate(()=>{window.hel776StateRules.forEach(([r,s])=>r.style.cssText=s);document.querySelector('#hel776-default-fixtures').remove()});protections.push({route,theme,actual,previous});
|
||||
}
|
||||
if(process.env.HEL776_PROTECT_ONLY==='1')for(const theme of ['night','day','grade','bluehour']){
|
||||
await p.goto(WEB);await p.evaluate(t=>localStorage.setItem('aigc-theme',t),theme);await p.reload();await p.locator('.studio-hero').waitFor();await settled(p);
|
||||
const al=p.locator('.home .a-btn.secondary.sm').first();await sp.goto('http://127.0.0.1:'+server.address().port);await sp.evaluate(({theme,text})=>{document.documentElement.dataset.theme=theme;const e=document.createElement('button');e.className='btn sm';e.id='small';e.textContent=text;document.body.append(e)}, {theme,text:await al.innerText()});await al.screenshot({path:out+'/closeups/'+theme+'-small-actual.png'});await sp.locator('#small').screenshot({path:out+'/closeups/'+theme+'-small-sample.png'});
|
||||
}
|
||||
ok('no browser page errors',errors.length===0);
|
||||
}finally{
|
||||
fs.writeFileSync(out+(process.env.HEL776_PROTECT_ONLY==='1'?'/protection-measurements.json':'/measurements.json'),JSON.stringify(rows,null,2));fs.writeFileSync(out+(process.env.HEL776_PROTECT_ONLY==='1'?'/protection-results.json':'/results.json'),JSON.stringify({checks,errors,protections},null,2));
|
||||
await c.tracing.stop({path:out+(process.env.HEL776_PROTECT_ONLY==='1'?'/protection-trace.zip':'/trace.zip')});console.log('TRACE_SAVED');
|
||||
// Bound browser shutdown; always collect an exit code instead of hanging after evidence.
|
||||
await Promise.race([b.close(),wait(5000).then(()=>{if(b.isConnected())throw Error('browser shutdown exceeded 5s')})]);await new Promise(r=>server.close(r));
|
||||
}
|
||||
})().then(()=>process.exit(0)).catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Run the isolated regression stack in the foreground and always stop children."""
|
||||
import os, signal, socket, subprocess, time, urllib.request
|
||||
from pathlib import Path
|
||||
root=Path(__file__).resolve().parents[1]
|
||||
os.chdir(root)
|
||||
py=os.environ.get('HEL731_PYTHON', 'python3')
|
||||
logs=root/'e2e/shots/hel731'; logs.mkdir(parents=True,exist_ok=True)
|
||||
env=os.environ.copy()
|
||||
env['PYTHONPATH']=str(root/'calliope-backend/src')
|
||||
for port in [8247,5173,18188,18189]:
|
||||
with socket.socket() as probe:
|
||||
assert probe.connect_ex(('127.0.0.1',port)) != 0, f'Test port {port} is already in use'
|
||||
# Every run gets fresh disposable state; retain prior evidence instead of deleting it.
|
||||
reference=root/'e2e/shots/reference'
|
||||
if reference.exists():reference.rename(reference.with_name('reference.previous-'+str(time.time_ns())))
|
||||
mode_path=root/'e2e/fake_mode.json'
|
||||
saved_mode=mode_path.read_bytes() if mode_path.exists() else None
|
||||
processes=[]; handles=[]
|
||||
try:
|
||||
for name,cmd in [('api',[py,'e2e/reference/server.py']),('comfy',[py,'e2e/fake_comfy.py']),('llm',[py,'e2e/reference/fake_llm.py']),('web',['npm','--prefix',os.environ.get('HEL731_WEB_DIR',os.environ.get('HEL729_WEB_DIR','calliope-web')),'run','dev'])]:
|
||||
h=open(logs/(name+'.log'),'w');handles.append(h);processes.append(subprocess.Popen(cmd,stdout=h,stderr=subprocess.STDOUT,env=env,start_new_session=True))
|
||||
for url in ['http://127.0.0.1:8247/api/health','http://127.0.0.1:5173']:
|
||||
for i in range(100):
|
||||
try:
|
||||
urllib.request.urlopen(url,timeout=2); break
|
||||
except Exception:
|
||||
if any(p.poll() is not None for p in processes): raise RuntimeError('A test service failed')
|
||||
if i==99:raise
|
||||
time.sleep(.2)
|
||||
for cmd in [[py,'e2e/seed.py'],[py,'e2e/reference/seed.py']]:subprocess.run(cmd,env=env,check=True)
|
||||
failures=[]
|
||||
scripts=os.environ.get('HEL731_TESTS',os.environ.get('HEL729_TESTS','e2e/hel731.cjs')).split(',')
|
||||
for script in scripts:
|
||||
print('RUN',script,flush=True)
|
||||
result=subprocess.run(['node',script],env=env)
|
||||
if result.returncode:failures.append(script)
|
||||
print('FAILED SCRIPTS',failures,flush=True)
|
||||
if failures:raise SystemExit(1)
|
||||
|
||||
finally:
|
||||
for p in processes:
|
||||
if p.poll() is None:os.killpg(p.pid,signal.SIGTERM)
|
||||
for p in processes:
|
||||
try:p.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait()
|
||||
for h in handles:h.close()
|
||||
|
||||
if saved_mode is not None:mode_path.write_bytes(saved_mode)
|
||||
elif mode_path.exists():mode_path.unlink()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""HEL-773: run the isolated stack, keep it alive for the audit script, then stop children."""
|
||||
import os, signal, socket, subprocess, time, urllib.request
|
||||
from pathlib import Path
|
||||
root=Path(__file__).resolve().parents[1]
|
||||
os.chdir(root)
|
||||
(root/'e2e/shots/hel731').mkdir(parents=True,exist_ok=True)
|
||||
py=os.environ.get('HEL731_PYTHON','python3')
|
||||
env=os.environ.copy()
|
||||
env['PYTHONPATH']=str(root/'calliope-backend/src')
|
||||
for port in [8247,5173,18188,18189]:
|
||||
with socket.socket() as probe:
|
||||
assert probe.connect_ex(('127.0.0.1',port))!=0, f'Test port {port} is already in use'
|
||||
reference=root/'e2e/shots/reference'
|
||||
if reference.exists():reference.rename(reference.with_name('reference.previous-'+str(time.time_ns())))
|
||||
processes=[];handles=[]
|
||||
try:
|
||||
for name,cmd in [('api',[py,'e2e/reference/server.py']),('comfy',[py,'e2e/fake_comfy.py']),('llm',[py,'e2e/reference/fake_llm.py']),('web',['npm','--prefix','calliope-web','run','dev'])]:
|
||||
h=open(f'../evidence/checks/{name}.log','w');handles.append(h);processes.append(subprocess.Popen(cmd,stdout=h,stderr=subprocess.STDOUT,env=env,start_new_session=True))
|
||||
for url in ['http://127.0.0.1:8247/api/health','http://127.0.0.1:5173']:
|
||||
for i in range(100):
|
||||
try:
|
||||
urllib.request.urlopen(url,timeout=2);break
|
||||
except Exception:
|
||||
if any(p.poll() is not None for p in processes):raise RuntimeError('A test service failed')
|
||||
if i==99:raise
|
||||
time.sleep(.2)
|
||||
for cmd in [[py,'e2e/seed.py'],[py,'e2e/reference/seed.py']]:subprocess.run(cmd,env=env,check=True)
|
||||
# seed one project with storyboard content like hel731.cjs does
|
||||
import json,urllib.request as u
|
||||
def api(p,b=None,method=None):
|
||||
req=u.Request('http://127.0.0.1:8247'+p,data=json.dumps(b).encode() if b is not None else None,method=method or ('POST' if b is not None else 'GET'),headers={'Content-Type':'application/json'})
|
||||
return json.loads(u.urlopen(req, timeout=15).read())
|
||||
proj=api('/api/projects',{'title':'隔离验证 · 海岸来信','idea':'纸船带回一片树叶'})
|
||||
fixtures={'script':{'text':'第一场:清晨的海岸,女孩把一只纸船放入海水。她等候着父亲的归来。第二场:纸船带回一片树叶,女孩望向海平面,知道旅程才刚刚开始。'},
|
||||
'style':{'directions':[{'id':'s1','name':'晨光','description':'暖光','prompt':'warm light'},{'id':'s2','name':'蓝调','description':'冷光','prompt':'blue hour'}]},
|
||||
'storyboard':{'shots':[{'id':'board-'+str(i),'scene':'1','shot_size':'远景','camera':'固定','description':'女孩沿着海岸走向灯塔,纸船随海风起伏。','dialog':('你好' if i%2 else ''),'duration_sec':5} for i in range(12)]},
|
||||
'assets':{'assets':[{'id':'asset-a','kind':'character','name':'海边女孩','description':'女孩','prompt':'girl on coast'}]},
|
||||
'shot':{'shots':[{'id':'shot-a','shot_no':'S1','description':'女孩走向灯塔','prompt':'girl walks toward lighthouse','duration_sec':4}]}}
|
||||
for stage,content in fixtures.items():
|
||||
api(f"/api/workbench/projects/{proj['id']}/stages/{stage}",{'content':content},'PATCH')
|
||||
if stage in ('script','style'):
|
||||
api(f"/api/workbench/projects/{proj['id']}/stages/{stage}/confirm",({'chosen_style_id':'s1'} if stage=='style' else {}))
|
||||
print('stack ready, project',proj['id'])
|
||||
result=subprocess.run(['node',os.environ.get('HEL774_SCRIPT','e2e/hel774.cjs')],env=env)
|
||||
print('TEST_PROCESS_EXIT',result.returncode,flush=True)
|
||||
raise SystemExit(result.returncode)
|
||||
finally:
|
||||
for p in processes:
|
||||
if p.poll() is None:os.killpg(p.pid,signal.SIGTERM)
|
||||
for p in processes:
|
||||
try:p.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait()
|
||||
for h in handles:h.close()
|
||||
import shutil
|
||||
if reference.exists():shutil.rmtree(reference)
|
||||
print('CLEANUP: all exact child process groups stopped; disposable reference DB removed',flush=True)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""HEL-776: run the isolated stack, keep it alive for the audit script, then stop children."""
|
||||
import os, signal, socket, subprocess, time, urllib.request
|
||||
from pathlib import Path
|
||||
root=Path(__file__).resolve().parents[1]
|
||||
os.chdir(root)
|
||||
(root/'e2e/shots/hel731').mkdir(parents=True,exist_ok=True)
|
||||
py=os.environ.get('HEL731_PYTHON','python3')
|
||||
env=os.environ.copy()
|
||||
env['PYTHONPATH']=str(root/'calliope-backend/src')
|
||||
for port in [8247,5173,18188,18189]:
|
||||
with socket.socket() as probe:
|
||||
assert probe.connect_ex(('127.0.0.1',port))!=0, f'Test port {port} is already in use'
|
||||
reference=root/'e2e/shots/reference'
|
||||
assert not reference.exists(), 'Refuse to overwrite existing isolation data'
|
||||
processes=[];handles=[]
|
||||
try:
|
||||
for name,cmd in [('api',[py,'e2e/reference/server.py']),('comfy',[py,'e2e/fake_comfy.py']),('llm',[py,'e2e/reference/fake_llm.py']),('web',['npm','--prefix','calliope-web','run','dev'])]:
|
||||
h=open(f'../evidence/checks/{name}.log','w');handles.append(h);processes.append(subprocess.Popen(cmd,stdout=h,stderr=subprocess.STDOUT,env=env,start_new_session=True))
|
||||
for url in ['http://127.0.0.1:8247/api/health','http://127.0.0.1:5173']:
|
||||
for i in range(100):
|
||||
try:
|
||||
urllib.request.urlopen(url,timeout=2);break
|
||||
except Exception:
|
||||
if any(p.poll() is not None for p in processes):raise RuntimeError('A test service failed')
|
||||
if i==99:raise
|
||||
time.sleep(.2)
|
||||
for cmd in [[py,'e2e/seed.py'],[py,'e2e/reference/seed.py']]:subprocess.run(cmd,env=env,check=True)
|
||||
# seed one project with storyboard content like hel731.cjs does
|
||||
import json,urllib.request as u
|
||||
def api(p,b=None,method=None):
|
||||
req=u.Request('http://127.0.0.1:8247'+p,data=json.dumps(b).encode() if b is not None else None,method=method or ('POST' if b is not None else 'GET'),headers={'Content-Type':'application/json'})
|
||||
return json.loads(u.urlopen(req, timeout=15).read())
|
||||
proj=api('/api/projects',{'title':'隔离验证 · 海岸来信','idea':'纸船带回一片树叶'})
|
||||
fixtures={'script':{'text':'第一场:清晨的海岸,女孩把一只纸船放入海水。她等候着父亲的归来。第二场:纸船带回一片树叶,女孩望向海平面,知道旅程才刚刚开始。'},
|
||||
'style':{'directions':[{'id':'s1','name':'晨光','description':'暖光','prompt':'warm light'},{'id':'s2','name':'蓝调','description':'冷光','prompt':'blue hour'}]},
|
||||
'storyboard':{'shots':[{'id':'board-'+str(i),'scene':'1','shot_size':'远景','camera':'固定','description':'女孩沿着海岸走向灯塔,纸船随海风起伏。','dialog':('你好' if i%2 else ''),'duration_sec':5} for i in range(12)]},
|
||||
'assets':{'assets':[{'id':'asset-a','kind':'character','name':'海边女孩','description':'女孩','prompt':'girl on coast'}]},
|
||||
'shot':{'shots':[{'id':'shot-a','shot_no':'S1','description':'女孩走向灯塔','prompt':'girl walks toward lighthouse','duration_sec':4}]}}
|
||||
for stage,content in fixtures.items():
|
||||
api(f"/api/workbench/projects/{proj['id']}/stages/{stage}",{'content':content},'PATCH')
|
||||
if stage in ('script','style'):
|
||||
api(f"/api/workbench/projects/{proj['id']}/stages/{stage}/confirm",({'chosen_style_id':'s1'} if stage=='style' else {}))
|
||||
print('stack ready, project',proj['id'],flush=True)
|
||||
Path('../evidence/checks/processes.json').write_text(json.dumps([{'pid':p.pid,'args':p.args} for p in processes],indent=2))
|
||||
result=subprocess.run(['node','e2e/hel776.cjs'],env=env)
|
||||
print('TEST_PROCESS_EXIT',result.returncode,flush=True)
|
||||
raise SystemExit(result.returncode)
|
||||
finally:
|
||||
daemon=json.loads(subprocess.check_output(['multica','daemon','status','--output','json']))['pid']
|
||||
assert all(p.pid != daemon for p in processes)
|
||||
for p in processes:
|
||||
if p.poll() is None:os.killpg(p.pid,signal.SIGTERM)
|
||||
for p in processes:
|
||||
try:p.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait()
|
||||
for h in handles:h.close()
|
||||
import shutil
|
||||
if reference.exists():shutil.rmtree(reference)
|
||||
print('CLEANUP: all exact child process groups stopped; disposable reference DB removed',flush=True)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Package B3 evidence without changing or retouching application screenshots."""
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
source = root / 'e2e/shots/hel731'
|
||||
out = root.parent / 'evidence/hel731'
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for path in source.iterdir():
|
||||
if path.is_file() and path.name != 'interaction-trace.zip':
|
||||
shutil.copy2(path, out / path.name)
|
||||
for actual in out.glob('*-actual.png'):
|
||||
reference = actual.with_name(actual.name.replace('-actual', '-sample'))
|
||||
left, right = Image.open(reference), Image.open(actual)
|
||||
pair = Image.new('RGB', (left.width + right.width, max(left.height, right.height)))
|
||||
pair.paste(left, (0, 0)); pair.paste(right, (left.width, 0))
|
||||
pair.save(actual.with_name(actual.name.replace('-actual', '-pair')))
|
||||
measurements = json.loads((source / 'measurements.json').read_text())
|
||||
with (out / 'measurements.csv').open('w', newline='') as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(['page', 'theme', 'viewport', 'selector', 'property', 'sample', 'actual', 'delta'])
|
||||
for row in measurements:
|
||||
for key, sample in row['sample'].items():
|
||||
actual = row['actual'][key]
|
||||
delta = round(actual - sample, 5) if isinstance(sample, (int, float)) else ''
|
||||
writer.writerow([row['page'], row['theme'], row['width'], row['selector'], key, sample, actual, delta])
|
||||
logs = out / 'checks'; logs.mkdir(exist_ok=True)
|
||||
for name in ['lint-b3.log', 'check-b3.log', 'build-b3.log', 'pytest-b3.log', 'frontend-test-b3.log', 'browser-b3.log', 'final-browser-b3.log', 'related-browser-b3.log', 'normalize-b3.log', 'theme-regression-b3.log']:
|
||||
shutil.copy2(root / name, logs / name)
|
||||
for suite in ['hel729', 'hel742']:
|
||||
shutil.copy2(root / f'e2e/shots/{suite}/results.json', logs / f'{suite}-results.json')
|
||||
shutil.copy2(root.parent / 'attachments/夜片场-多主题交互样本.html', out / 'approved-sample.html')
|
||||
print('Evidence copied to', out.name)
|
||||
Reference in New Issue
Block a user