Compare commits

..
Author SHA1 Message Date
leeferandmultica-agent b2bcbc10df fix(HEL-769): restore theme tile preview details from sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 07:07:44 +08:00
leeferandmultica-agent 2004f527cc fix(HEL-767): align management header, theme cards and shared rail with sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 06:38:32 +08:00
leeferandmultica-agent dca88b3765 fix(HEL-751): restore confirmed full-width task table
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 05:18:07 +08:00
leeferandmultica-agent 54880e900b fix: restore library entrance and document video caption exception
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 04:44:33 +08:00
leeferandmultica-agent 4c849e9ef9 docs: explain 29 scoped lint exceptions for HEL-762 batch 4
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 04:14:43 +08:00
leeferandmultica-agent 46fd5bbc86 fix: use Svelte reactive collections at isolated boundaries (HEL-760)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:51:16 +08:00
leeferandmultica-agent fc6616b118 fix: preserve resource link semantics with external rel (HEL-758)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:32:35 +08:00
leeferandmultica-agent 22f82fbed6 docs: record HEL-756 batch 1 implementation and validation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:07:08 +08:00
leeferandmultica-agent 67eb822efe fix(navigation): keep tool destinations in a module constant
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:02:47 +08:00
leeferandmultica-agent 99574c2226 fix(state): preserve readonly runes and remove inert export flag
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:22 +08:00
leeferandmultica-agent 88965fa70d fix(lists): key 48 loops with verified identity or positional semantics
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:22 +08:00
leeferandmultica-agent 9d54b9d8dd fix(navigation): resolve the 74 approved application destinations
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:12 +08:00
leeferandmultica-agent a666f6bd12 docs: record HEL-753 joint type validation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent b02c50fdf7 fix(shot): type mirror and posture boundaries without assertions
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent 7e9a5be19d fix(shot): declare real mannequin joints and numeric accessors
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent e49b8b0072 docs(HEL-752): record validation and unresolved behavioral gates
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:25:13 +08:00
leeferandmultica-agent ddc02fbe4c chore(HEL-752): retain unchanged SCSS package registry entry
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:21:00 +08:00
leeferandmultica-agent d076978a4a refactor(HEL-752): remove unused breakpoint bookkeeping
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:20:23 +08:00
leeferandmultica-agent 4e200cc8ac refactor(HEL-752): type menu and blend contracts and document local exceptions
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:18:32 +08:00
leeferandmultica-agent bfcf542e59 refactor(HEL-752): remove unused locals while preserving evaluation and props
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:16:48 +08:00
leeferandmultica-agent 3d1c4ca5af fix(HEL-752): use Svelte-aware const checks and preserve legacy rules
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:15:16 +08:00
leeferandmultica-agent 24129438e8 chore(HEL-750): configure ESLint 9 and document remaining lint blockers
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:02:58 +08:00
leeferandmultica-agent 155d914df3 style(HEL-750): apply existing Prettier formatting to 61 files
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:57:34 +08:00
152 changed files with 30106 additions and 23559 deletions
+49
View File
@@ -0,0 +1,49 @@
import js from "@eslint/js";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import svelte from "eslint-plugin-svelte";
export default [
// SvelteKit sync and adapter-static output; these are not source files.
{ ignores: [".svelte-kit/**", "build/**"] },
js.configs.recommended,
...svelte.configs["flat/recommended"],
// v3 removed these from recommended; retain the previous checks explicitly.
{
rules: {
"svelte/valid-compile": "error",
"svelte/no-dynamic-slot-name": "error",
},
},
{
files: ["**/*.{ts,svelte}"],
plugins: { "@typescript-eslint": tsPlugin },
rules: {
...tsPlugin.configs["eslint-recommended"].overrides[0].rules,
...tsPlugin.configs.recommended.rules,
},
},
{
files: ["**/*.ts"],
ignores: ["**/*.svelte.ts"],
languageOptions: { parser: tsParser },
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: { parserOptions: { parser: tsParser } },
rules: {
"prefer-const": "off",
"svelte/prefer-const": "error",
},
},
{
files: ["src/**/*.{js,ts,svelte}"],
ignores: ["src/hooks.server.ts"],
languageOptions: { globals: globals.browser },
},
{
files: ["*.{js,ts}", "scripts/**/*.mjs", "src/hooks.server.ts"],
languageOptions: { globals: globals.node },
},
];
+4511 -4256
View File
File diff suppressed because it is too large Load Diff
+39 -37
View File
@@ -1,39 +1,41 @@
{
"name": "calliope-web",
"version": "1.5.8",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.13.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.0",
"eslint-plugin-svelte": "^2.46.0",
"postcss": "^8.5.0",
"prettier": "^3.5.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.20.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.0",
"vite": "^6.1.0"
},
"dependencies": {
"@tanstack/svelte-query": "^5.66.0",
"@types/three": "0.185.0",
"@xyflow/svelte": "^1.6.5",
"mannequin-js": "5.2.3",
"three": "0.185.1"
},
"type": "module"
"name": "calliope-web",
"version": "1.5.8",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.13.0",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.0",
"eslint-plugin-svelte": "3.23.1",
"postcss": "^8.5.0",
"prettier": "^3.5.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.20.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.0",
"vite": "^6.1.0"
},
"dependencies": {
"@tanstack/svelte-query": "^5.66.0",
"@types/three": "0.185.0",
"@xyflow/svelte": "^1.6.5",
"mannequin-js": "5.2.3",
"three": "0.185.1"
},
"type": "module"
}
+105 -94
View File
@@ -16,51 +16,53 @@
*
* Run with `npm test` (or `node scripts/check-storage-ssr.mjs`).
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
const SRC = join(ROOT, 'src');
const HELPER = join(SRC, 'lib', 'storage.ts');
const ROOT = join(HERE, "..");
const SRC = join(ROOT, "src");
const HELPER = join(SRC, "lib", "storage.ts");
let failures = 0;
function fail(message) {
failures += 1;
console.error(`FAIL ${message}`);
failures += 1;
console.error(`FAIL ${message}`);
}
function ok(message) {
console.log(`ok ${message}`);
console.log(`ok ${message}`);
}
/** Replace the global under test. Node's own binding may be getter-only. */
function setGlobalStorage(value) {
Object.defineProperty(globalThis, 'localStorage', {
value,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "localStorage", {
value,
configurable: true,
writable: true,
});
}
/** A Storage that behaves like a browser's. */
function fakeStorage(initial = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => void map.set(k, String(v)),
removeItem: (k) => void map.delete(k),
clear: () => map.clear(),
key: (i) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
const map = new Map(Object.entries(initial));
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => void map.set(k, String(v)),
removeItem: (k) => void map.delete(k),
clear: () => map.clear(),
key: (i) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
}
const { storageGet, storageSet, storageRemove } = await import(pathToFileURL(HELPER).href);
const { storageGet, storageSet, storageRemove } = await import(
pathToFileURL(HELPER).href
);
// ---- 1. behavioural ----------------------------------------------------
@@ -68,124 +70,133 @@ const { storageGet, storageSet, storageRemove } = await import(pathToFileURL(HEL
// This is the configuration that produced the original crash.
setGlobalStorage({});
try {
if (storageGet('calliope-lang') !== null) fail('method-less localStorage: expected null');
else if (storageSet('calliope-lang', 'ja') !== undefined) fail('method-less localStorage: setItem should no-op');
else {
storageRemove('calliope-lang');
ok('method-less localStorage (Node SSR shape) degrades to null without throwing');
}
if (storageGet("calliope-lang") !== null)
fail("method-less localStorage: expected null");
else if (storageSet("calliope-lang", "ja") !== undefined)
fail("method-less localStorage: setItem should no-op");
else {
storageRemove("calliope-lang");
ok(
"method-less localStorage (Node SSR shape) degrades to null without throwing",
);
}
} catch (err) {
fail(`method-less localStorage threw: ${err.message}`);
fail(`method-less localStorage threw: ${err.message}`);
}
// Absent entirely (the common server case).
setGlobalStorage(undefined);
try {
if (storageGet('k') !== null) fail('undefined localStorage: expected null');
else ok('absent localStorage degrades to null');
if (storageGet("k") !== null) fail("undefined localStorage: expected null");
else ok("absent localStorage degrades to null");
} catch (err) {
fail(`undefined localStorage threw: ${err.message}`);
fail(`undefined localStorage threw: ${err.message}`);
}
// Access itself can throw (sandboxed frame / blocked third-party storage).
setGlobalStorage(
new Proxy(
{},
{
get() {
throw new Error('SecurityError');
},
},
),
new Proxy(
{},
{
get() {
throw new Error("SecurityError");
},
},
),
);
try {
if (storageGet('k') !== null) fail('throwing localStorage: expected null');
else ok('throwing localStorage access degrades to null');
if (storageGet("k") !== null) fail("throwing localStorage: expected null");
else ok("throwing localStorage access degrades to null");
} catch (err) {
fail(`throwing localStorage access threw: ${err.message}`);
fail(`throwing localStorage access threw: ${err.message}`);
}
// Quota / private-mode errors on write must not escape.
setGlobalStorage({
getItem: () => null,
setItem: () => {
throw new Error('QuotaExceededError');
},
removeItem: () => {},
getItem: () => null,
setItem: () => {
throw new Error("QuotaExceededError");
},
removeItem: () => {},
});
try {
storageSet('k', 'v');
ok('setItem failure is swallowed');
storageSet("k", "v");
ok("setItem failure is swallowed");
} catch (err) {
fail(`setItem failure escaped: ${err.message}`);
fail(`setItem failure escaped: ${err.message}`);
}
// A real browser Storage must still work — guards against a helper that just
// returns null unconditionally to satisfy the checks above.
setGlobalStorage(fakeStorage());
try {
storageSet('calliope-lang', 'ko');
const read = storageGet('calliope-lang');
if (read !== 'ko') fail(`working storage round-trip: expected "ko", got ${JSON.stringify(read)}`);
else {
storageRemove('calliope-lang');
if (storageGet('calliope-lang') !== null) fail('working storage remove: expected null');
else ok('working storage round-trips and removes');
}
storageSet("calliope-lang", "ko");
const read = storageGet("calliope-lang");
if (read !== "ko")
fail(
`working storage round-trip: expected "ko", got ${JSON.stringify(read)}`,
);
else {
storageRemove("calliope-lang");
if (storageGet("calliope-lang") !== null)
fail("working storage remove: expected null");
else ok("working storage round-trips and removes");
}
} catch (err) {
fail(`working storage threw: ${err.message}`);
fail(`working storage threw: ${err.message}`);
}
// ---- 2. static ---------------------------------------------------------
const UNSAFE = /typeof\s+localStorage/;
const EXTENSIONS = ['.ts', '.js', '.svelte'];
const EXTENSIONS = [".ts", ".js", ".svelte"];
function walk(dir) {
const found = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue;
found.push(...walk(full));
} else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
found.push(full);
}
}
return found;
const found = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules") continue;
found.push(...walk(full));
} else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
found.push(full);
}
}
return found;
}
function exists(p) {
try {
return statSync(p).isFile();
} catch {
return false;
}
try {
return statSync(p).isFile();
} catch {
return false;
}
}
const offenders = [];
for (const file of walk(SRC)) {
if (file === HELPER) continue; // the one place allowed to probe the global
const lines = readFileSync(file, 'utf8').split('\n');
lines.forEach((line, i) => {
if (UNSAFE.test(line)) offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
});
if (file === HELPER) continue; // the one place allowed to probe the global
const lines = readFileSync(file, "utf8").split("\n");
lines.forEach((line, i) => {
if (UNSAFE.test(line))
offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
});
}
if (offenders.length) {
fail(
`unsafe \`typeof localStorage\` guard outside lib/storage.ts — use storageGet/storageSet instead:\n ${offenders.join('\n ')}`,
);
fail(
`unsafe \`typeof localStorage\` guard outside lib/storage.ts — use storageGet/storageSet instead:\n ${offenders.join("\n ")}`,
);
} else {
ok('no unsafe `typeof localStorage` guards in src/');
ok("no unsafe `typeof localStorage` guards in src/");
}
if (!exists(HELPER)) fail('src/lib/storage.ts is missing');
if (!exists(HELPER)) fail("src/lib/storage.ts is missing");
// ---- result ------------------------------------------------------------
if (failures) {
console.error(`\n${failures} storage/SSR check(s) failed.`);
process.exit(1);
console.error(`\n${failures} storage/SSR check(s) failed.`);
process.exit(1);
}
console.log('\nstorage SSR checks passed.');
console.log("\nstorage SSR checks passed.");
+6 -6
View File
@@ -1,9 +1,9 @@
import type { HandleClientError } from '@sveltejs/kit';
import { t } from '$lib/i18n.svelte';
import type { HandleClientError } from "@sveltejs/kit";
import { t } from "$lib/i18n.svelte";
export const handleError: HandleClientError = ({ error }) => {
console.error(error);
return {
message: t('global.somethingWentWrong'),
};
console.error(error);
return {
message: t("global.somethingWentWrong"),
};
};
+2 -2
View File
@@ -1,4 +1,4 @@
export async function handle({ event, resolve }) {
const response = await resolve(event);
return response;
const response = await resolve(event);
return response;
}
+13 -13
View File
@@ -1,17 +1,17 @@
import type { Action } from 'svelte/action';
import type { Action } from "svelte/action";
/** A local honeycomb highlight follows the pointer, independent of selected state. */
export const spotlight: Action<HTMLElement> = (node) => {
const move = (event: PointerEvent) => {
if (
document.documentElement.dataset.reduce === '1' ||
event.pointerType === 'touch'
)
return;
const rect = node.getBoundingClientRect();
node.style.setProperty('--pointer-x', `${event.clientX - rect.left}px`);
node.style.setProperty('--pointer-y', `${event.clientY - rect.top}px`);
};
node.addEventListener('pointermove', move);
return { destroy: () => node.removeEventListener('pointermove', move) };
const move = (event: PointerEvent) => {
if (
document.documentElement.dataset.reduce === "1" ||
event.pointerType === "touch"
)
return;
const rect = node.getBoundingClientRect();
node.style.setProperty("--pointer-x", `${event.clientX - rect.left}px`);
node.style.setProperty("--pointer-y", `${event.clientY - rect.top}px`);
};
node.addEventListener("pointermove", move);
return { destroy: () => node.removeEventListener("pointermove", move) };
};
+51 -42
View File
@@ -2,50 +2,50 @@
* Shared types and helpers for the Agent composer (@workflow mentions + attachments).
*/
export type WorkflowKind = 'image' | 'video';
export type AttachmentKind = 'image' | 'video' | 'audio' | 'document';
export type WorkflowKind = "image" | "video";
export type AttachmentKind = "image" | "video" | "audio" | "document";
export interface WorkflowMention {
type: 'workflow';
id: number;
name: string;
kind: WorkflowKind;
type: "workflow";
id: number;
name: string;
kind: WorkflowKind;
}
export interface AgentAttachment {
path: string;
name: string;
kind: AttachmentKind;
path: string;
name: string;
kind: AttachmentKind;
}
export interface AgentComposerPayload {
content: string;
mentions: (WorkflowMention | SkillMention)[];
attachments: AgentAttachment[];
/** question/asked seq this message answers (question-card click). */
answer_to?: number;
content: string;
mentions: (WorkflowMention | SkillMention)[];
attachments: AgentAttachment[];
/** question/asked seq this message answers (question-card click). */
answer_to?: number;
}
export interface WorkflowOption {
id: number;
name: string;
kind: WorkflowKind;
description?: string | null;
is_enabled?: boolean;
id: number;
name: string;
kind: WorkflowKind;
description?: string | null;
is_enabled?: boolean;
}
export interface SkillOption {
name: string;
description: string;
tags: string[];
name: string;
description: string;
tags: string[];
}
/** A `/skill` command inserted as a chip. Serialized into the message's
* mentions payload so the backend can project it into the LLM context. */
export interface SkillMention {
type: 'skill';
name: string;
description: string;
type: "skill";
name: string;
description: string;
}
export const MAX_WORKFLOW_MENTIONS = 1;
@@ -53,27 +53,36 @@ export const MAX_WORKFLOW_MENTIONS = 1;
export const MENTION_LIMIT = 12;
/** Case-insensitive name filter of enabled workflows, capped for the typeahead. */
export function filterWorkflows(workflows: WorkflowOption[], query: string): WorkflowOption[] {
const q = query.trim().toLowerCase();
const enabled = workflows.filter((w) => w.is_enabled !== false);
const matched = q
? enabled.filter((w) => w.name.toLowerCase().includes(q))
: enabled;
return matched.slice(0, MENTION_LIMIT);
export function filterWorkflows(
workflows: WorkflowOption[],
query: string,
): WorkflowOption[] {
const q = query.trim().toLowerCase();
const enabled = workflows.filter((w) => w.is_enabled !== false);
const matched = q
? enabled.filter((w) => w.name.toLowerCase().includes(q))
: enabled;
return matched.slice(0, MENTION_LIMIT);
}
/** Case-insensitive filter of skills for the `/` typeahead (name + description). */
export function filterSkills(skills: SkillOption[], query: string): SkillOption[] {
const q = query.trim().toLowerCase();
const matched = q
? skills.filter(
(s) =>
s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
)
: skills;
return matched.slice(0, MENTION_LIMIT);
export function filterSkills(
skills: SkillOption[],
query: string,
): SkillOption[] {
const q = query.trim().toLowerCase();
const matched = q
? skills.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q),
)
: skills;
return matched.slice(0, MENTION_LIMIT);
}
export function payloadIsEmpty(p: AgentComposerPayload): boolean {
return !p.content.trim() && p.mentions.length === 0 && p.attachments.length === 0;
return (
!p.content.trim() && p.mentions.length === 0 && p.attachments.length === 0
);
}
+13 -11
View File
@@ -7,28 +7,30 @@
* buttons stay dumb and the wording never drifts out of sync.
*/
export type AgentTaskKind = 'story' | 'script';
export type AgentTaskKind = "story" | "script";
/** The pre-filled composer prompt for each task. */
export const AGENT_TASK_PROMPTS: Record<AgentTaskKind, string> = {
story:
'Draft the storyline for this project — generate the beats, characters, environments, and any misc. items from the story idea.',
script:
'Regenerate the full script for this project — turn the storyline into ordered scenes with action and dialogue.',
story:
"Draft the storyline for this project — generate the beats, characters, environments, and any misc. items from the story idea.",
script:
"Regenerate the full script for this project — turn the storyline into ordered scenes with action and dialogue.",
};
/** i18n key for the auto-created session's sidebar title. */
export const AGENT_TASK_TITLE_KEYS: Record<AgentTaskKind, string> = {
story: 'agent.task.story',
script: 'agent.task.script',
story: "agent.task.story",
script: "agent.task.script",
};
export function isAgentTaskKind(value: string | null | undefined): value is AgentTaskKind {
return value === 'story' || value === 'script';
export function isAgentTaskKind(
value: string | null | undefined,
): value is AgentTaskKind {
return value === "story" || value === "script";
}
/** Build the /agents deep-link URL for a project-scoped task. */
export function agentDeepLink(projectId: number, task: AgentTaskKind): string {
const params = new URLSearchParams({ project: String(projectId), task });
return `/agents?${params.toString()}`;
const params = new URLSearchParams({ project: String(projectId), task });
return `/agents?${params.toString()}`;
}
+41 -31
View File
@@ -1,50 +1,60 @@
/** Shared shape for Omni / video ref pickers. */
export type AssetGroup = 'character' | 'location' | 'item' | 'upload' | 'clip' | 'shot';
export type AssetGroup =
"character" | "location" | "item" | "upload" | "clip" | "shot";
export interface AssetOption {
label: string;
path: string;
kind?: 'image' | 'video' | 'audio';
group?: AssetGroup;
label: string;
path: string;
kind?: "image" | "video" | "audio";
group?: AssetGroup;
}
const GROUP_FROM_SUFFIX: Array<{ re: RegExp; group: AssetGroup }> = [
{ re: /\s·\s*sheet$/i, group: 'character' },
{ re: /\s·\s*environment$/i, group: 'location' },
{ re: /\s·\s*item$/i, group: 'item' },
{ re: /\s·\s*upload$/i, group: 'upload' },
{ re: /\s·\s*blockout(\s+clip)?$/i, group: 'shot' },
{ re: /^clip\s*#/i, group: 'clip' },
{ re: /\s·\s*sheet$/i, group: "character" },
{ re: /\s·\s*environment$/i, group: "location" },
{ re: /\s·\s*item$/i, group: "item" },
{ re: /\s·\s*upload$/i, group: "upload" },
{ re: /\s·\s*blockout(\s+clip)?$/i, group: "shot" },
{ re: /^clip\s*#/i, group: "clip" },
];
export function assetGroup(opt: AssetOption): AssetGroup {
if (opt.group) return opt.group;
for (const { re, group } of GROUP_FROM_SUFFIX) {
if (re.test(opt.label)) return group;
}
return 'upload';
if (opt.group) return opt.group;
for (const { re, group } of GROUP_FROM_SUFFIX) {
if (re.test(opt.label)) return group;
}
return "upload";
}
/** Strip the " · sheet" style suffix so the tab already names the type. */
export function assetDisplayName(opt: AssetOption): string {
return opt.label.replace(/\s·\s*(sheet|environment|item|upload|blockout(\s+clip)?)\s*$/i, '').trim() || opt.label;
return (
opt.label
.replace(
/\s·\s*(sheet|environment|item|upload|blockout(\s+clip)?)\s*$/i,
"",
)
.trim() || opt.label
);
}
export const ASSET_GROUP_TABS: Array<{ id: AssetGroup; label: string }> = [
{ id: 'character', label: 'Characters' },
{ id: 'location', label: 'Environments' },
{ id: 'item', label: 'Misc. Items' },
{ id: 'shot', label: 'From Build Scene' },
{ id: 'clip', label: 'Clips' },
{ id: 'upload', label: 'Uploads' },
{ id: "character", label: "Characters" },
{ id: "location", label: "Environments" },
{ id: "item", label: "Misc. Items" },
{ id: "shot", label: "From Build Scene" },
{ id: "clip", label: "Clips" },
{ id: "upload", label: "Uploads" },
];
export function tabsForMediaKind(kind: string | undefined): Array<{ id: AssetGroup; label: string }> {
const ids: AssetGroup[] =
kind === 'video'
? ['clip', 'upload']
: kind === 'audio'
? ['upload']
: ['character', 'location', 'item', 'shot', 'upload'];
return ASSET_GROUP_TABS.filter((t) => ids.includes(t.id));
export function tabsForMediaKind(
kind: string | undefined,
): Array<{ id: AssetGroup; label: string }> {
const ids: AssetGroup[] =
kind === "video"
? ["clip", "upload"]
: kind === "audio"
? ["upload"]
: ["character", "location", "item", "shot", "upload"];
return ASSET_GROUP_TABS.filter((t) => ids.includes(t.id));
}
@@ -47,14 +47,12 @@
</header>
<div class="media">
{#if videoSrc}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<button
type="button"
class="media-btn"
onclick={openPreview}
title={t('canvas.openPreview')}
>
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="media-el"
src={videoSrc}
@@ -64,7 +62,6 @@
></video>
</button>
{:else if mediaSrc}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<button
type="button"
class="media-btn"
@@ -76,7 +76,6 @@
<SafeMedia class="media-el" src={posterSrc} alt={data.title} kind="image" controls={false} />
{:else if videoSrc}
<!-- No poster available: #t=0.1 first-frame thumbnail. -->
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="media-el"
src={videoSrc}
+102 -96
View File
@@ -12,24 +12,25 @@
* control → compact pills (resolution, duration, seed)
* advanced → hidden inside an "Advanced" popover
*/
import type { ComfyDynamicInput } from './types';
import { normalizeInputRole } from './parser';
import type { ComfyDynamicInput } from "./types";
import { normalizeInputRole } from "./parser";
export type InputZone = 'composer' | 'media' | 'control' | 'advanced' | 'hidden';
export type InputZone =
"composer" | "media" | "control" | "advanced" | "hidden";
export type OmniWidget =
| 'promptTextarea'
| 'negativeTextarea'
| 'uploadTile'
| 'assetTile'
| 'resolutionPill'
| 'numberPill'
| 'textPill';
| "promptTextarea"
| "negativeTextarea"
| "uploadTile"
| "assetTile"
| "resolutionPill"
| "numberPill"
| "textPill";
export interface ClassifiedInput {
input: ComfyDynamicInput;
zone: InputZone;
widget: OmniWidget;
input: ComfyDynamicInput;
zone: InputZone;
widget: OmniWidget;
}
/**
@@ -39,49 +40,49 @@ export interface ClassifiedInput {
* zone. Unknown roles fall to `advanced` so no field is ever hidden or lost.
*/
export function classifyInput(inp: ComfyDynamicInput): ClassifiedInput {
const role = normalizeInputRole(inp.role);
const role = normalizeInputRole(inp.role);
// --- Composer ---
if (role === 'prompt' || role === 'positive') {
return { input: inp, zone: 'composer', widget: 'promptTextarea' };
}
if (role === 'negative') {
return { input: inp, zone: 'composer', widget: 'negativeTextarea' };
}
// --- Composer ---
if (role === "prompt" || role === "positive") {
return { input: inp, zone: "composer", widget: "promptTextarea" };
}
if (role === "negative") {
return { input: inp, zone: "composer", widget: "negativeTextarea" };
}
// --- Media tray ---
if (
role === 'character' ||
role === 'location' ||
role === 'image' ||
role === 'video' ||
role === 'audio'
) {
const isMediaKind =
inp.kind === 'image' ||
inp.kind === 'image_url' ||
inp.kind === 'audio' ||
inp.kind === 'video';
return {
input: inp,
zone: 'media',
widget: isMediaKind ? 'uploadTile' : 'assetTile',
};
}
// --- Media tray ---
if (
role === "character" ||
role === "location" ||
role === "image" ||
role === "video" ||
role === "audio"
) {
const isMediaKind =
inp.kind === "image" ||
inp.kind === "image_url" ||
inp.kind === "audio" ||
inp.kind === "video";
return {
input: inp,
zone: "media",
widget: isMediaKind ? "uploadTile" : "assetTile",
};
}
// --- Control bar ---
if (role === 'width' || role === 'height') {
return { input: inp, zone: 'control', widget: 'resolutionPill' };
}
if (role === 'duration' || role === 'seed') {
return { input: inp, zone: 'control', widget: 'numberPill' };
}
// --- Control bar ---
if (role === "width" || role === "height") {
return { input: inp, zone: "control", widget: "resolutionPill" };
}
if (role === "duration" || role === "seed") {
return { input: inp, zone: "control", widget: "numberPill" };
}
// --- Advanced popover ---
if (inp.kind === 'number') {
return { input: inp, zone: 'advanced', widget: 'numberPill' };
}
return { input: inp, zone: 'advanced', widget: 'textPill' };
// --- Advanced popover ---
if (inp.kind === "number") {
return { input: inp, zone: "advanced", widget: "numberPill" };
}
return { input: inp, zone: "advanced", widget: "textPill" };
}
/**
@@ -92,57 +93,62 @@ export function classifyInput(inp: ComfyDynamicInput): ClassifiedInput {
* nodeIds on change.
*/
export function classifyAll(inputs: ComfyDynamicInput[]): {
prompt: ClassifiedInput | null;
negative: ClassifiedInput | null;
media: ClassifiedInput[];
control: ClassifiedInput[];
advanced: ClassifiedInput[];
/** Non-null when both width AND height are present → merge into one pill. */
resolutionPair: { width: ClassifiedInput; height: ClassifiedInput } | null;
prompt: ClassifiedInput | null;
negative: ClassifiedInput | null;
media: ClassifiedInput[];
control: ClassifiedInput[];
advanced: ClassifiedInput[];
/** Non-null when both width AND height are present → merge into one pill. */
resolutionPair: { width: ClassifiedInput; height: ClassifiedInput } | null;
} {
const classified = inputs.map(classifyInput);
const classified = inputs.map(classifyInput);
const prompt = classified.find((c) => c.widget === 'promptTextarea') ?? null;
const negative = classified.find((c) => c.widget === 'negativeTextarea') ?? null;
const media = classified.filter((c) => c.zone === 'media');
const advanced = classified.filter((c) => c.zone === 'advanced');
const prompt = classified.find((c) => c.widget === "promptTextarea") ?? null;
const negative =
classified.find((c) => c.widget === "negativeTextarea") ?? null;
const media = classified.filter((c) => c.zone === "media");
const advanced = classified.filter((c) => c.zone === "advanced");
// Separate resolution from other control inputs
const controlNonRes = classified.filter(
(c) => c.zone === 'control' && c.widget !== 'resolutionPill',
);
const resInputs = classified.filter((c) => c.widget === 'resolutionPill');
// Separate resolution from other control inputs
const controlNonRes = classified.filter(
(c) => c.zone === "control" && c.widget !== "resolutionPill",
);
const resInputs = classified.filter((c) => c.widget === "resolutionPill");
// Detect width + height pair
const wInput = resInputs.find((c) => normalizeInputRole(c.input.role) === 'width');
const hInput = resInputs.find((c) => normalizeInputRole(c.input.role) === 'height');
const resolutionPair =
wInput && hInput ? { width: wInput, height: hInput } : null;
// Detect width + height pair
const wInput = resInputs.find(
(c) => normalizeInputRole(c.input.role) === "width",
);
const hInput = resInputs.find(
(c) => normalizeInputRole(c.input.role) === "height",
);
const resolutionPair =
wInput && hInput ? { width: wInput, height: hInput } : null;
// If only one of width/height exists, treat it as a standalone control number pill
const standaloneRes = resolutionPair ? [] : resInputs;
// If only one of width/height exists, treat it as a standalone control number pill
const standaloneRes = resolutionPair ? [] : resInputs;
return {
prompt,
negative,
media,
control: [...controlNonRes, ...standaloneRes],
advanced,
resolutionPair,
};
return {
prompt,
negative,
media,
control: [...controlNonRes, ...standaloneRes],
advanced,
resolutionPair,
};
}
// ── Resolution presets ────────────────────────────────────────────────────────
export interface ResolutionPreset {
label: string;
width: number;
height: number;
label: string;
width: number;
height: number;
}
export const RESOLUTION_PRESETS: ResolutionPreset[] = [
{ label: '720p', width: 1280, height: 720 },
{ label: '1080p', width: 1920, height: 1080 },
{ label: "720p", width: 1280, height: 720 },
{ label: "1080p", width: 1920, height: 1080 },
];
/**
@@ -150,12 +156,12 @@ export const RESOLUTION_PRESETS: ResolutionPreset[] = [
* (or return null for custom / unset values).
*/
export function resolutionLabel(
width: number | string | undefined,
height: number | string | undefined,
width: number | string | undefined,
height: number | string | undefined,
): string | null {
const w = Number(width);
const h = Number(height);
if (!w || !h) return null;
const match = RESOLUTION_PRESETS.find((p) => p.width === w && p.height === h);
return match?.label ?? `${w}×${h}`;
const w = Number(width);
const h = Number(height);
if (!w || !h) return null;
const match = RESOLUTION_PRESETS.find((p) => p.width === w && p.height === h);
return match?.label ?? `${w}×${h}`;
}
+163 -113
View File
@@ -1,142 +1,192 @@
import type { ComfyDynamicInput, ComfyDynamicOutput, WorkflowNode } from './types';
import type {
ComfyDynamicInput,
ComfyDynamicOutput,
WorkflowNode,
} from "./types";
const TEXT_AREA = new Set([
'CLIPTextEncode',
'Note',
'ShowText',
'ImpactWildcardProcessor',
'PrimitiveStringMultiline',
'PrimitiveString',
"CLIPTextEncode",
"Note",
"ShowText",
"ImpactWildcardProcessor",
"PrimitiveStringMultiline",
"PrimitiveString",
]);
const NUMBER = new Set([
"INT",
"FLOAT",
"PrimitiveInt",
"PrimitiveFloat",
"KSampler",
"KSamplerAdvanced",
]);
const IMAGE = new Set([
"LoadImage",
"LoadImageMask",
"ImageLoader",
"ETN_LoadImageBase64",
]);
const IMAGE_URL = new Set(["Load Image From Url (mtb)"]);
const AUDIO = new Set(["LoadAudio", "VHS_LoadAudio"]);
const VIDEO = new Set(["LoadVideo", "VHS_LoadVideo", "VHS_LoadVideoPath"]);
const VIDEO_OUT = new Set([
"VHS_VideoCombine",
"SaveVideo",
"VideoOutput",
"AnimateDiffCombine",
]);
const IMAGE_OUT = new Set([
"SaveImage",
"PreviewImage",
"SaveImageWebsocket",
"ETN_SendImageWebSocket",
]);
const NUMBER = new Set(['INT', 'FLOAT', 'PrimitiveInt', 'PrimitiveFloat', 'KSampler', 'KSamplerAdvanced']);
const IMAGE = new Set(['LoadImage', 'LoadImageMask', 'ImageLoader', 'ETN_LoadImageBase64']);
const IMAGE_URL = new Set(['Load Image From Url (mtb)']);
const AUDIO = new Set(['LoadAudio', 'VHS_LoadAudio']);
const VIDEO = new Set(['LoadVideo', 'VHS_LoadVideo', 'VHS_LoadVideoPath']);
const VIDEO_OUT = new Set(['VHS_VideoCombine', 'SaveVideo', 'VideoOutput', 'AnimateDiffCombine']);
const IMAGE_OUT = new Set(['SaveImage', 'PreviewImage', 'SaveImageWebsocket', 'ETN_SendImageWebSocket']);
/** Matches (Input), (Input:prompt), (Output:image), … */
const TITLE_TAG_RE = /\((Input|Output)(?::([a-zA-Z0-9_-]+))?\)/i;
const INPUT_ROLE_ALIASES: Record<string, string[]> = {
prompt: ['prompt', 'positive'],
negative: ['negative', 'neg'],
width: ['width', 'w'],
height: ['height', 'h'],
character: ['character', 'char', 'portrait', 'sheet', 'face', 'ref'],
location: ['location', 'loc', 'environment', 'env', 'background', 'scene'],
image: ['image', 'img'],
video: ['video', 'vid'],
audio: ['audio', 'sound', 'sfx'],
seed: ['seed'],
duration: ['duration', 'dur', 'length', 'seconds'],
prompt: ["prompt", "positive"],
negative: ["negative", "neg"],
width: ["width", "w"],
height: ["height", "h"],
character: ["character", "char", "portrait", "sheet", "face", "ref"],
location: ["location", "loc", "environment", "env", "background", "scene"],
image: ["image", "img"],
video: ["video", "vid"],
audio: ["audio", "sound", "sfx"],
seed: ["seed"],
duration: ["duration", "dur", "length", "seconds"],
};
const OUTPUT_ROLE_ALIASES: Record<string, string[]> = {
image: ['image', 'img'],
video: ['video', 'vid'],
image: ["image", "img"],
video: ["video", "vid"],
};
export function parseTitleTag(title: string): {
kind: 'input' | 'output' | null;
role: string | null;
label: string;
kind: "input" | "output" | null;
role: string | null;
label: string;
} {
if (!title) return { kind: null, role: null, label: '' };
const match = TITLE_TAG_RE.exec(title);
if (!match) return { kind: null, role: null, label: title.trim() };
const kind = match[1].toLowerCase() as 'input' | 'output';
const role = match[2] ? match[2].toLowerCase() : null;
const stripped = title.replace(TITLE_TAG_RE, '').trim();
const label = stripped || role || kind;
return { kind, role, label };
if (!title) return { kind: null, role: null, label: "" };
const match = TITLE_TAG_RE.exec(title);
if (!match) return { kind: null, role: null, label: title.trim() };
const kind = match[1].toLowerCase() as "input" | "output";
const role = match[2] ? match[2].toLowerCase() : null;
const stripped = title.replace(TITLE_TAG_RE, "").trim();
const label = stripped || role || kind;
return { kind, role, label };
}
export function normalizeInputRole(role: string | null | undefined): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(INPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
export function normalizeInputRole(
role: string | null | undefined,
): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(INPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
}
export function normalizeOutputRole(role: string | null | undefined): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(OUTPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
export function normalizeOutputRole(
role: string | null | undefined,
): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(OUTPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
}
function classToInputKind(classType: string): ComfyDynamicInput['kind'] {
if (IMAGE.has(classType)) return 'image';
if (IMAGE_URL.has(classType)) return 'image_url';
if (AUDIO.has(classType)) return 'audio';
if (VIDEO.has(classType)) return 'video';
if (NUMBER.has(classType)) return 'number';
if (TEXT_AREA.has(classType)) return 'textarea';
const lower = classType.toLowerCase();
if (lower.includes('video')) return 'video';
if (lower.includes('audio')) return 'audio';
if (lower.includes('image') || lower.includes('load')) return 'image';
if (lower.includes('int') || lower.includes('float') || lower.includes('seed')) return 'number';
if (lower.includes('text') || lower.includes('clip') || lower.includes('prompt')) return 'textarea';
return 'text';
function classToInputKind(classType: string): ComfyDynamicInput["kind"] {
if (IMAGE.has(classType)) return "image";
if (IMAGE_URL.has(classType)) return "image_url";
if (AUDIO.has(classType)) return "audio";
if (VIDEO.has(classType)) return "video";
if (NUMBER.has(classType)) return "number";
if (TEXT_AREA.has(classType)) return "textarea";
const lower = classType.toLowerCase();
if (lower.includes("video")) return "video";
if (lower.includes("audio")) return "audio";
if (lower.includes("image") || lower.includes("load")) return "image";
if (
lower.includes("int") ||
lower.includes("float") ||
lower.includes("seed")
)
return "number";
if (
lower.includes("text") ||
lower.includes("clip") ||
lower.includes("prompt")
)
return "textarea";
return "text";
}
function extractDefault(node: WorkflowNode): string | number | undefined {
const { inputs, class_type } = node;
if (IMAGE.has(class_type) || AUDIO.has(class_type) || VIDEO.has(class_type)) return undefined;
if (typeof inputs.text === 'string') return inputs.text;
if (typeof inputs.value === 'string' || typeof inputs.value === 'number') return inputs.value;
if (typeof inputs.int === 'number') return inputs.int;
if (typeof inputs.float === 'number') return inputs.float;
return undefined;
const { inputs, class_type } = node;
if (IMAGE.has(class_type) || AUDIO.has(class_type) || VIDEO.has(class_type))
return undefined;
if (typeof inputs.text === "string") return inputs.text;
if (typeof inputs.value === "string" || typeof inputs.value === "number")
return inputs.value;
if (typeof inputs.int === "number") return inputs.int;
if (typeof inputs.float === "number") return inputs.float;
return undefined;
}
export function parseDynamicInputs(workflow: Record<string, WorkflowNode>): ComfyDynamicInput[] {
const results: ComfyDynamicInput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? '';
const { kind, role, label } = parseTitleTag(title);
if (kind !== 'input') continue;
results.push({
nodeId,
label: label || node.class_type,
role: normalizeInputRole(role),
kind: classToInputKind(node.class_type),
defaultValue: extractDefault(node),
required: true,
});
}
return results;
export function parseDynamicInputs(
workflow: Record<string, WorkflowNode>,
): ComfyDynamicInput[] {
const results: ComfyDynamicInput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? "";
const { kind, role, label } = parseTitleTag(title);
if (kind !== "input") continue;
results.push({
nodeId,
label: label || node.class_type,
role: normalizeInputRole(role),
kind: classToInputKind(node.class_type),
defaultValue: extractDefault(node),
required: true,
});
}
return results;
}
export function parseDynamicOutputs(workflow: Record<string, WorkflowNode>): ComfyDynamicOutput[] {
const results: ComfyDynamicOutput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? '';
const { kind, role, label } = parseTitleTag(title);
if (kind !== 'output') continue;
const canon = normalizeOutputRole(role);
let outKind: ComfyDynamicOutput['kind'] = 'other';
if (canon === 'video' || VIDEO_OUT.has(node.class_type) || node.class_type.toLowerCase().includes('video'))
outKind = 'video';
else if (
canon === 'image' ||
IMAGE_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes('image')
)
outKind = 'image';
results.push({
nodeId,
label: label || node.class_type,
role: canon,
kind: outKind,
});
}
return results;
export function parseDynamicOutputs(
workflow: Record<string, WorkflowNode>,
): ComfyDynamicOutput[] {
const results: ComfyDynamicOutput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? "";
const { kind, role, label } = parseTitleTag(title);
if (kind !== "output") continue;
const canon = normalizeOutputRole(role);
let outKind: ComfyDynamicOutput["kind"] = "other";
if (
canon === "video" ||
VIDEO_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes("video")
)
outKind = "video";
else if (
canon === "image" ||
IMAGE_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes("image")
)
outKind = "image";
results.push({
nodeId,
label: label || node.class_type,
role: canon,
kind: outKind,
});
}
return results;
}
+36 -32
View File
@@ -1,51 +1,55 @@
import type { ComfyDynamicInput } from './types';
import { normalizeInputRole } from './parser';
import type { ComfyDynamicInput } from "./types";
import { normalizeInputRole } from "./parser";
const PROMPT_ROLES = new Set(['prompt', 'positive']);
const PROMPT_ROLES = new Set(["prompt", "positive"]);
function roleOf(inp: ComfyDynamicInput): string | null {
return normalizeInputRole(inp.role ?? null);
return normalizeInputRole(inp.role ?? null);
}
/** Positive prompt input — prefers (Input:prompt), legacy label fallback. */
export function isPromptLikeInput(inp: ComfyDynamicInput): boolean {
const role = roleOf(inp);
if (role && PROMPT_ROLES.has(role)) return true;
if (role) return false; // other roles are never "the" prompt
if (inp.kind !== 'text' && inp.kind !== 'textarea') return false;
const label = (inp.label || '').toLowerCase();
if (label.includes('negative')) return false;
return (
label.includes('prompt') ||
label.includes('positive') ||
label.includes('text') ||
inp.kind === 'textarea'
);
const role = roleOf(inp);
if (role && PROMPT_ROLES.has(role)) return true;
if (role) return false; // other roles are never "the" prompt
if (inp.kind !== "text" && inp.kind !== "textarea") return false;
const label = (inp.label || "").toLowerCase();
if (label.includes("negative")) return false;
return (
label.includes("prompt") ||
label.includes("positive") ||
label.includes("text") ||
inp.kind === "textarea"
);
}
/** Drop prompt-role fields from shared workflow forms (entity Image prompt owns those). */
export function withoutPromptInputs(inputs: ComfyDynamicInput[] | undefined | null): ComfyDynamicInput[] {
if (!inputs?.length) return [];
return inputs.filter((inp) => !isPromptLikeInput(inp));
export function withoutPromptInputs(
inputs: ComfyDynamicInput[] | undefined | null,
): ComfyDynamicInput[] {
if (!inputs?.length) return [];
return inputs.filter((inp) => !isPromptLikeInput(inp));
}
/** True if this workflow schema has a prompt-role (or legacy prompt-like) (Input). */
export function workflowHasPromptInput(inputs: ComfyDynamicInput[] | undefined | null): boolean {
if (!inputs?.length) return false;
return inputs.some(isPromptLikeInput);
export function workflowHasPromptInput(
inputs: ComfyDynamicInput[] | undefined | null,
): boolean {
if (!inputs?.length) return false;
return inputs.some(isPromptLikeInput);
}
/** Strip blank strings so empty fields cannot wipe smart-fill. */
export function compactInputValues(
values: Record<string, unknown> | undefined | null,
values: Record<string, unknown> | undefined | null,
): Record<string, unknown> {
if (!values) return {};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(values)) {
if (v === null || v === undefined) continue;
if (typeof v === 'string' && !v.trim()) continue;
if (Array.isArray(v) && !v.length) continue;
out[k] = v;
}
return out;
if (!values) return {};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(values)) {
if (v === null || v === undefined) continue;
if (typeof v === "string" && !v.trim()) continue;
if (Array.isArray(v) && !v.length) continue;
out[k] = v;
}
return out;
}
+26 -23
View File
@@ -3,39 +3,42 @@
* ratios and rounding, so the size shown is the size ComfyUI renders.
*/
const RATIOS: Record<string, [number, number]> = {
'1:1 (Square)': [1, 1],
'2:3 (Portrait Photo)': [2, 3],
'3:2 (Photo)': [3, 2],
'3:4 (Portrait Standard)': [3, 4],
'4:3 (Standard)': [4, 3],
'9:16 (Portrait Widescreen)': [9, 16],
'16:9 (Widescreen)': [16, 9],
'21:9 (Ultrawide)': [21, 9],
"1:1 (Square)": [1, 1],
"2:3 (Portrait Photo)": [2, 3],
"3:2 (Photo)": [3, 2],
"3:4 (Portrait Standard)": [3, 4],
"4:3 (Standard)": [4, 3],
"9:16 (Portrait Widescreen)": [9, 16],
"16:9 (Widescreen)": [16, 9],
"21:9 (Ultrawide)": [21, 9],
};
/** Python's round(): halves go to the even neighbour. */
function roundHalfEven(x: number): number {
const f = Math.floor(x);
const diff = x - f;
if (diff > 0.5) return f + 1;
if (diff < 0.5) return f;
return f % 2 === 0 ? f : f + 1;
const f = Math.floor(x);
const diff = x - f;
if (diff > 0.5) return f + 1;
if (diff < 0.5) return f;
return f % 2 === 0 ? f : f + 1;
}
export function resolutionSelectorSize(
aspectRatio: string,
megapixels: number,
multiple: number,
aspectRatio: string,
megapixels: number,
multiple: number,
): { width: number; height: number } | null {
const ratio = RATIOS[aspectRatio];
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
const [w, h] = ratio;
const m = Math.max(1, Math.floor(multiple) || 8);
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
return { width: roundHalfEven((w * scale) / m) * m, height: roundHalfEven((h * scale) / m) * m };
const ratio = RATIOS[aspectRatio];
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
const [w, h] = ratio;
const m = Math.max(1, Math.floor(multiple) || 8);
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
return {
width: roundHalfEven((w * scale) / m) * m,
height: roundHalfEven((h * scale) / m) * m,
};
}
/** "16:9 (Widescreen)" → "16:9". */
export function ratioShort(option: string): string {
return option.split(' ')[0] ?? option;
return option.split(" ")[0] ?? option;
}
+288 -278
View File
@@ -1,178 +1,184 @@
export type ComfyInputKind =
| 'text'
| 'textarea'
| 'number'
| 'select'
| 'image'
| 'image_url'
| 'audio'
| 'video'
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
| 'image_list'
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
| 'video_list'
| 'audio_list';
export type ComfyOutputKind = 'image' | 'video' | 'other';
| "text"
| "textarea"
| "number"
| "select"
| "image"
| "image_url"
| "audio"
| "video"
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
| "image_list"
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
| "video_list"
| "audio_list";
export type ComfyOutputKind = "image" | "video" | "other";
/** Structural finding on an imported workflow; `error` blocks generation. */
export interface WorkflowWarning {
level: 'error' | 'warning';
code: string;
node_id: string;
message: string;
level: "error" | "warning";
code: string;
node_id: string;
message: string;
}
export interface ComfyDynamicInput {
nodeId: string;
label: string;
/** Role from title tag, e.g. (Input:prompt) → "prompt". Null for plain (Input). */
role?: string | null;
kind: ComfyInputKind;
defaultValue?: string | number;
required: boolean;
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
auto?: boolean;
/** ComfyUI widget limits (number kind). */
min?: number;
max?: number;
step?: number;
integer?: boolean;
/** Allowed values (select kind). */
options?: string[];
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
resolution?: { megapixels: string; multiple: number };
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
media_hint?: string;
nodeId: string;
label: string;
/** Role from title tag, e.g. (Input:prompt) → "prompt". Null for plain (Input). */
role?: string | null;
kind: ComfyInputKind;
defaultValue?: string | number;
required: boolean;
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
auto?: boolean;
/** ComfyUI widget limits (number kind). */
min?: number;
max?: number;
step?: number;
integer?: boolean;
/** Allowed values (select kind). */
options?: string[];
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
resolution?: { megapixels: string; multiple: number };
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
media_hint?: string;
}
export interface ComfyDynamicOutput {
nodeId: string;
label: string;
/** Role from title tag, e.g. (Output:image). */
role?: string | null;
/** Null while an unknown custom node awaits the user's media-type pick. */
kind: ComfyOutputKind | null;
/** Untagged detection: node class + which rule typed it. */
class_type?: string;
evidence?: 'registry' | 'class_name_match' | 'unknown_node';
/** Form default for unknown candidates — never auto-applied. */
suggested_kind?: 'image' | 'video' | null;
nodeId: string;
label: string;
/** Role from title tag, e.g. (Output:image). */
role?: string | null;
/** Null while an unknown custom node awaits the user's media-type pick. */
kind: ComfyOutputKind | null;
/** Untagged detection: node class + which rule typed it. */
class_type?: string;
evidence?: "registry" | "class_name_match" | "unknown_node";
/** Form default for unknown candidates — never auto-applied. */
suggested_kind?: "image" | "video" | null;
}
/** One entry of a workflow's confirmed output mapping (HEL-638). */
export interface WorkflowOutputMapEntry {
nodeId: string;
kind: 'image' | 'video';
label?: string;
class_type?: string;
nodeId: string;
kind: "image" | "video";
label?: string;
class_type?: string;
}
/** The five workflow usages the studio distinguishes (HEL-636). */
export type WorkflowUsage =
| 'text2image'
| 'image2image'
| 'text2video'
| 'image2video'
| 'ref2video';
"text2image" | "image2image" | "text2video" | "image2video" | "ref2video";
export const WORKFLOW_USAGES: WorkflowUsage[] = [
'text2image',
'image2image',
'text2video',
'image2video',
'ref2video',
"text2image",
"image2image",
"text2video",
"image2video",
"ref2video",
];
/** Which usages each generate page serves. */
export const USAGE_BY_KIND: Record<'image' | 'video', WorkflowUsage[]> = {
image: ['text2image', 'image2image'],
video: ['text2video', 'image2video', 'ref2video'],
export const USAGE_BY_KIND: Record<"image" | "video", WorkflowUsage[]> = {
image: ["text2image", "image2image"],
video: ["text2video", "image2video", "ref2video"],
};
/** One L2 connection-check finding against a live ComfyUI (HEL-643). */
export interface PreflightFinding {
level: 'error' | 'warn' | 'info' | 'ok';
code: string;
node_id?: string;
node_title?: string | null;
message: string;
hint?: string | null;
level: "error" | "warn" | "info" | "ok";
code: string;
node_id?: string;
node_title?: string | null;
message: string;
hint?: string | null;
}
/** Result of checking a workflow against a target connection's /object_info. */
export interface WorkflowConnectionCheck {
endpoint_id: string | null;
endpoint_name: string | null;
base_url?: string | null;
/** False when any error-level finding exists (or the server was unreachable). */
ok: boolean;
/** False → 无法核查 (server down): honest, non-blocking. */
reachable: boolean;
findings: PreflightFinding[];
message?: string | null;
endpoint_id: string | null;
endpoint_name: string | null;
base_url?: string | null;
/** False when any error-level finding exists (or the server was unreachable). */
ok: boolean;
/** False → 无法核查 (server down): honest, non-blocking. */
reachable: boolean;
findings: PreflightFinding[];
message?: string | null;
}
/** Slim live record of a workflow's latest test run (HEL-643). */
export interface WorkflowLastTest {
id: number;
status: string;
phase: string | null;
error: string | null;
created_at: string;
completed_at: string | null;
output_count: number;
id: number;
status: string;
phase: string | null;
error: string | null;
created_at: string;
completed_at: string | null;
output_count: number;
}
/** Result of POST /api/workflows/analyze (import confirm step 1). */
export interface WorkflowAnalyzeResult {
inputs: ComfyDynamicInput[];
outputs: ComfyDynamicOutput[];
suggested_profile?: string;
warnings?: WorkflowWarning[];
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
needs_mapping: boolean;
usage_problem: string | null;
usage_evidence: {
outputs: ComfyDynamicOutput[];
image_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
video_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
summary: string[];
};
usable_usage: WorkflowUsage[];
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
connection_check?: WorkflowConnectionCheck;
inputs: ComfyDynamicInput[];
outputs: ComfyDynamicOutput[];
suggested_profile?: string;
warnings?: WorkflowWarning[];
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
needs_mapping: boolean;
usage_problem: string | null;
usage_evidence: {
outputs: ComfyDynamicOutput[];
image_slots: Array<{
nodeId: string;
label: string;
kind: string;
role: string | null;
}>;
video_slots: Array<{
nodeId: string;
label: string;
kind: string;
role: string | null;
}>;
summary: string[];
};
usable_usage: WorkflowUsage[];
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
connection_check?: WorkflowConnectionCheck;
}
export interface WorkflowNode {
class_type: string;
inputs: Record<string, unknown>;
_meta?: { title?: string };
class_type: string;
inputs: Record<string, unknown>;
_meta?: { title?: string };
}
export interface Workflow {
id: number;
name: string;
kind: 'image' | 'video';
/** Five-way usage (HEL-636); null = pending human confirmation. */
usage?: WorkflowUsage | null;
usage_source?: 'auto' | 'user' | null;
workflow_json: Record<string, WorkflowNode>;
input_schema: ComfyDynamicInput[];
output_schema: ComfyDynamicOutput[];
description: string | null;
prompt_profile: string;
is_enabled: boolean;
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
endpoint_id?: string | null;
warnings?: WorkflowWarning[];
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
output_map?: WorkflowOutputMapEntry[] | null;
output_map_source?: 'auto' | 'user' | null;
/** True when the graph has unknown output nodes needing designation. */
needs_mapping?: boolean;
/** Latest test-run record, queried live (HEL-643); null = never tested. */
last_test?: WorkflowLastTest | null;
id: number;
name: string;
kind: "image" | "video";
/** Five-way usage (HEL-636); null = pending human confirmation. */
usage?: WorkflowUsage | null;
usage_source?: "auto" | "user" | null;
workflow_json: Record<string, WorkflowNode>;
input_schema: ComfyDynamicInput[];
output_schema: ComfyDynamicOutput[];
description: string | null;
prompt_profile: string;
is_enabled: boolean;
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
endpoint_id?: string | null;
warnings?: WorkflowWarning[];
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
output_map?: WorkflowOutputMapEntry[] | null;
output_map_source?: "auto" | "user" | null;
/** True when the graph has unknown output nodes needing designation. */
needs_mapping?: boolean;
/** Latest test-run record, queried live (HEL-643); null = never tested. */
last_test?: WorkflowLastTest | null;
}
/**
@@ -180,195 +186,199 @@ export interface Workflow {
* point is browsing 150+ workflows without downloading workflow_json.
*/
export interface WorkflowMediaIO {
in: string[];
out: string[];
in: string[];
out: string[];
}
export interface WorkflowMeta {
id: number;
name: string;
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_source: 'auto' | 'user' | null;
is_enabled: boolean;
category: string | null;
model_family: string | null;
media_io: WorkflowMediaIO;
tags: string[];
favorite: boolean;
last_used_at: string | null;
description: string | null;
source: string | null;
source_path: string | null;
prompt_profile: string;
created_at: string;
endpoint_id: string | null;
output_map_source: 'auto' | 'user' | null;
/** Cheap pending check: usage or output mapping not settled yet. */
needs_mapping: boolean;
id: number;
name: string;
kind: "image" | "video";
usage: WorkflowUsage | null;
usage_source: "auto" | "user" | null;
is_enabled: boolean;
category: string | null;
model_family: string | null;
media_io: WorkflowMediaIO;
tags: string[];
favorite: boolean;
last_used_at: string | null;
description: string | null;
source: string | null;
source_path: string | null;
prompt_profile: string;
created_at: string;
endpoint_id: string | null;
output_map_source: "auto" | "user" | null;
/** Cheap pending check: usage or output mapping not settled yet. */
needs_mapping: boolean;
}
/** One UI-format file submitted to the batch convert/import pipeline. */
export interface WorkflowUIFileInput {
name?: string;
source_path?: string;
workflow_json: Record<string, unknown>;
usage?: WorkflowUsage;
description?: string;
tags?: string[];
name?: string;
source_path?: string;
workflow_json: Record<string, unknown>;
usage?: WorkflowUsage;
description?: string;
tags?: string[];
}
export interface WorkflowConvertIssue {
node_id: string;
code: string;
level: 'error' | 'warning';
message: string;
node_id: string;
code: string;
level: "error" | "warning";
message: string;
}
export interface WorkflowConvertSuggested {
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
category: string | null;
category_label: string | null;
model_family: string | null;
media_in: string[];
media_out: string[];
description: string | null;
kind: "image" | "video";
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
category: string | null;
category_label: string | null;
model_family: string | null;
media_in: string[];
media_out: string[];
description: string | null;
}
export interface WorkflowPreflightFinding {
level: string;
code: string;
node_id?: string;
node_title?: string;
message: string;
hint?: string;
level: string;
code: string;
node_id?: string;
node_title?: string;
message: string;
hint?: string;
}
export interface WorkflowConvertFileReport {
name: string;
source_path: string | null;
status: 'ok' | 'warn' | 'fail';
needs_confirmation: boolean;
issues: WorkflowConvertIssue[];
importable: boolean;
skip_reason?: string | null;
suggested?: WorkflowConvertSuggested;
preflight?: {
endpoint_id: string | null;
ok: boolean;
findings: WorkflowPreflightFinding[];
};
name: string;
source_path: string | null;
status: "ok" | "warn" | "fail";
needs_confirmation: boolean;
issues: WorkflowConvertIssue[];
importable: boolean;
skip_reason?: string | null;
suggested?: WorkflowConvertSuggested;
preflight?: {
endpoint_id: string | null;
ok: boolean;
findings: WorkflowPreflightFinding[];
};
}
/** Dry-run report (POST /convert, POST /import-scan with dry_run). */
export interface WorkflowConvertReport {
total: number;
ok: number;
warn: number;
fail: number;
needs_confirmation: number;
importable: number;
files: WorkflowConvertFileReport[];
total: number;
ok: number;
warn: number;
fail: number;
needs_confirmation: number;
importable: number;
files: WorkflowConvertFileReport[];
}
export interface WorkflowImportFileReport extends WorkflowConvertFileReport {
result?: 'imported' | 'skipped' | 'failed';
id?: number;
existing_id?: number;
result?: "imported" | "skipped" | "failed";
id?: number;
existing_id?: number;
}
/** Commit result (POST /import-batch, POST /import-scan without dry_run). */
export interface WorkflowImportResult {
total: number;
imported: number;
skipped: number;
imported_ids: number[];
skipped_files: { name: string; source_path: string | null; reason: string | null }[];
files: WorkflowImportFileReport[];
total: number;
imported: number;
skipped: number;
imported_ids: number[];
skipped_files: {
name: string;
source_path: string | null;
reason: string | null;
}[];
files: WorkflowImportFileReport[];
}
export interface Job {
id: number;
project_id: number;
scene_id: number | null;
/** Render unit — video jobs are clip jobs (scene_id stays for grouping). */
clip_id: number | null;
kind: string;
workflow_id: number | null;
status: string;
payload: Record<string, unknown>;
output_paths: string[];
error: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
retry_count: number;
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
comfy_prompt_id?: string | null;
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
phase?: string | null;
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
origin?: string;
/** HEL-684: present when this take has been flowed back to its
* workbench source row (link job id on the source project). */
workbench_attach?: {
job_id: number;
stage: string;
target_id: string;
};
id: number;
project_id: number;
scene_id: number | null;
/** Render unit — video jobs are clip jobs (scene_id stays for grouping). */
clip_id: number | null;
kind: string;
workflow_id: number | null;
status: string;
payload: Record<string, unknown>;
output_paths: string[];
error: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
retry_count: number;
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
comfy_prompt_id?: string | null;
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
phase?: string | null;
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
origin?: string;
/** HEL-684: present when this take has been flowed back to its
* workbench source row (link job id on the source project). */
workbench_attach?: {
job_id: number;
stage: string;
target_id: string;
};
}
export interface SceneVideoSettings {
input_values?: Record<string, string | number>;
/** 'auto' | 'upload' | target scene id as string. */
clip_source?: string;
form_workflow_id?: number;
prompt_draft?: string;
prompt_draft_meta?: { based_on?: string; saved_at?: string };
[key: string]: unknown;
input_values?: Record<string, string | number>;
/** 'auto' | 'upload' | target scene id as string. */
clip_source?: string;
form_workflow_id?: number;
prompt_draft?: string;
prompt_draft_meta?: { based_on?: string; saved_at?: string };
[key: string]: unknown;
}
export interface Scene {
id: number;
project_id: number;
order_index: number;
heading: string | null;
action: string | null;
dialog: string | null;
duration_sec: number | null;
workflow_id: number | null;
env_image_path: string | null;
location_id: number | null;
video_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
character_ids: number[];
characters: Array<{
id: number;
name: string;
role: string | null;
portrait_path: string | null;
sheet_path: string | null;
}>;
clips: Clip[];
id: number;
project_id: number;
order_index: number;
heading: string | null;
action: string | null;
dialog: string | null;
duration_sec: number | null;
workflow_id: number | null;
env_image_path: string | null;
location_id: number | null;
video_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
character_ids: number[];
characters: Array<{
id: number;
name: string;
role: string | null;
portrait_path: string | null;
sheet_path: string | null;
}>;
clips: Clip[];
}
/** Shot unit: one scene expands into 1+ clips; each renders one ~5-10s video. */
export interface Clip {
id: number;
scene_id: number;
project_id: number;
order_index: number;
description: string | null;
shot_size: string | null;
dialog_lines_covered: number[] | null;
duration_sec: number | null;
workflow_id: number | null;
clip_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
/** '#3.2' — scene 3, clip 2 (display label). */
label?: string;
id: number;
scene_id: number;
project_id: number;
order_index: number;
description: string | null;
shot_size: string | null;
dialog_lines_covered: number[] | null;
duration_sec: number | null;
workflow_id: number | null;
clip_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
/** '#3.2' — scene 3, clip 2 (display label). */
label?: string;
}
+74 -70
View File
@@ -8,92 +8,96 @@
* - upload progress per nodeId
* - metadata (name, kind) for uploaded files
*/
import { playgroundApi, type UploadKind } from '$lib/api';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { playgroundApi, type UploadKind } from "$lib/api";
import { toast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
export interface UploadState {
/** nodeId → uploading filename (present while upload is in-flight) */
uploading: Record<string, string>;
/** path → { name, kind } for previously uploaded files */
uploadedMeta: Record<string, { name: string; kind: UploadKind }>;
/** nodeId → uploading filename (present while upload is in-flight) */
uploading: Record<string, string>;
/** path → { name, kind } for previously uploaded files */
uploadedMeta: Record<string, { name: string; kind: UploadKind }>;
}
export function createUploadManager() {
let uploading = $state<Record<string, string>>({});
let uploadedMeta = $state<Record<string, { name: string; kind: UploadKind }>>({});
let uploading = $state<Record<string, string>>({});
let uploadedMeta = $state<Record<string, { name: string; kind: UploadKind }>>(
{},
);
return {
get uploading() {
return uploading;
},
get uploadedMeta() {
return uploadedMeta;
},
return {
get uploading() {
return uploading;
},
get uploadedMeta() {
return uploadedMeta;
},
isUploading(nodeId: string): boolean {
return !!uploading[nodeId];
},
isUploading(nodeId: string): boolean {
return !!uploading[nodeId];
},
uploadMetaFor(path: string): { name: string; kind: UploadKind } {
const meta = uploadedMeta[path];
if (meta) return meta;
const base = path.split(/[/\\]/).pop() ?? path;
const ext = base.slice(base.lastIndexOf('.')).toLowerCase();
if (['.mp4', '.webm', '.mov', '.mkv'].includes(ext))
return { name: base, kind: 'video' };
if (['.mp3', '.wav', '.flac', '.ogg', '.m4a'].includes(ext))
return { name: base, kind: 'audio' };
return { name: base, kind: 'image' };
},
uploadMetaFor(path: string): { name: string; kind: UploadKind } {
const meta = uploadedMeta[path];
if (meta) return meta;
const base = path.split(/[/\\]/).pop() ?? path;
const ext = base.slice(base.lastIndexOf(".")).toLowerCase();
if ([".mp4", ".webm", ".mov", ".mkv"].includes(ext))
return { name: base, kind: "video" };
if ([".mp3", ".wav", ".flac", ".ogg", ".m4a"].includes(ext))
return { name: base, kind: "audio" };
return { name: base, kind: "image" };
},
/**
* Upload a file and return the server path.
* Manages uploading state and metadata internally.
* Throws on failure (caller handles toast if needed).
*/
async upload(nodeId: string, file: File): Promise<string> {
uploading = { ...uploading, [nodeId]: file.name };
try {
const res = await playgroundApi.upload(file);
uploadedMeta = {
...uploadedMeta,
[res.path]: { name: res.name, kind: res.kind },
};
return res.path;
} finally {
const next = { ...uploading };
delete next[nodeId];
uploading = next;
}
},
/**
* Upload a file and return the server path.
* Manages uploading state and metadata internally.
* Throws on failure (caller handles toast if needed).
*/
async upload(nodeId: string, file: File): Promise<string> {
uploading = { ...uploading, [nodeId]: file.name };
try {
const res = await playgroundApi.upload(file);
uploadedMeta = {
...uploadedMeta,
[res.path]: { name: res.name, kind: res.kind },
};
return res.path;
} finally {
const next = { ...uploading };
delete next[nodeId];
uploading = next;
}
},
/**
* Convenience: upload + toast on error, return path or null.
*/
async uploadSafe(nodeId: string, file: File): Promise<string | null> {
try {
return await this.upload(nodeId, file);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('toast.uploadFailed'));
return null;
}
},
};
/**
* Convenience: upload + toast on error, return path or null.
*/
async uploadSafe(nodeId: string, file: File): Promise<string | null> {
try {
return await this.upload(nodeId, file);
} catch (err) {
toast.error(
err instanceof Error ? err.message : t("toast.uploadFailed"),
);
return null;
}
},
};
}
export type UploadManager = ReturnType<typeof createUploadManager>;
export function truncateMiddle(name: string, max = 32): string {
if (name.length <= max) return name;
const head = Math.ceil((max - 1) / 2);
const tail = Math.floor((max - 1) / 2);
return `${name.slice(0, head)}…${name.slice(name.length - tail)}`;
if (name.length <= max) return name;
const head = Math.ceil((max - 1) / 2);
const tail = Math.floor((max - 1) / 2);
return `${name.slice(0, head)}…${name.slice(name.length - tail)}`;
}
export function acceptForKind(kind: string): string {
if (kind === 'audio') return 'audio/*';
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
if (kind === 'document') return '.txt,.md,.docx';
return 'image/*';
if (kind === "audio") return "audio/*";
if (kind === "video") return "video/*,.mp4,.webm,.mov,.mkv";
if (kind === "document") return ".txt,.md,.docx";
return "image/*";
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import { t } from '$lib/i18n.svelte';
@@ -15,7 +16,7 @@ interface Props {
<header class="header">
<div class="header-left">
<a class="logo" href="/" title={t('shell.brand')}>
<a class="logo" href={resolve('/')} title={t('shell.brand')}>
{t('shell.brand')}
</a>
{#if crumb}
@@ -27,13 +28,13 @@ interface Props {
</div>
{/if}
<nav class="top-nav" aria-label={t('nav.primary')}>
<a class="nav-link" class:active={active === 'canvas'} href="/canvas">{t('nav.canvas')}</a>
<a class="nav-link" class:active={active === 'projects'} href="/">{t('nav.projects')}</a>
<a class="nav-link" class:active={active === 'build-scene'} href="/build-scene">{t('nav.buildScene')}</a>
<a class="nav-link" class:active={active === 'library'} href="/library">{t('nav.library')}</a>
<a class="nav-link" class:active={active === 'agents'} href="/agents">{t('nav.agents')}</a>
<a class="nav-link" class:active={active === 'models'} href="/models">{t('nav.models')}</a>
<a class="nav-link" class:active={active === 'settings'} href="/settings">{t('nav.settings')}</a>
<a class="nav-link" class:active={active === 'canvas'} href={resolve('/canvas')}>{t('nav.canvas')}</a>
<a class="nav-link" class:active={active === 'projects'} href={resolve('/')}>{t('nav.projects')}</a>
<a class="nav-link" class:active={active === 'build-scene'} href={resolve('/build-scene')}>{t('nav.buildScene')}</a>
<a class="nav-link" class:active={active === 'library'} href={resolve('/library')}>{t('nav.library')}</a>
<a class="nav-link" class:active={active === 'agents'} href={resolve('/agents')}>{t('nav.agents')}</a>
<a class="nav-link" class:active={active === 'models'} href={resolve('/models')}>{t('nav.models')}</a>
<a class="nav-link" class:active={active === 'settings'} href={resolve('/settings')}>{t('nav.settings')}</a>
</nav>
</div>
<div class="header-right">
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createMutation, createQuery } from '@tanstack/svelte-query';
import {
playgroundApi,
@@ -137,9 +138,6 @@
const projectList = $derived(($projectsQuery.data ?? []) as Project[]);
// Five-stage workbench URL contract: images land on 素材资产, clips on 镜头脚本.
const stageLink = $derived(
attached ? `/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}` : '',
);
</script>
<div class="attach">
@@ -147,7 +145,7 @@
<div class="attached" role="status">
<span class="attached-check" aria-hidden="true"><Icon name="check" size={14} /></span>
<span class="attached-text">{t('attach.addedTo')} <strong>{attached.title}</strong></span>
<a class="attached-link" href={stageLink}>{t('attach.viewInProject')}</a>
<a class="attached-link" href={resolve(`/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}`)}>{t('attach.viewInProject')}</a>
<button
class="attached-dismiss"
type="button"
@@ -173,7 +171,7 @@
<span class="field-label">{t('attach.projectField')}</span>
<select class="field-select" bind:value={projectId}>
<option value="">{t('attach.selectProject')}</option>
{#each projectList as p}
{#each projectList as p (p.id)}
<option value={p.id}>{p.title}</option>
{/each}
</select>
@@ -194,7 +192,7 @@
<span class="field-label">{t('attach.characterField')}</span>
<select class="field-select" bind:value={characterId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectCharacter')}</option>
{#each characters as c}
{#each characters as c (c.id)}
<option value={c.id}>{c.name}</option>
{/each}
</select>
@@ -207,7 +205,7 @@
<span class="field-label">{t('attach.locationField')}</span>
<select class="field-select" bind:value={locationId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectLocation')}</option>
{#each locations as loc}
{#each locations as loc (loc.id)}
<option value={loc.id}>{loc.name}</option>
{/each}
</select>
@@ -232,7 +230,7 @@
<span class="field-label">{t('attach.sceneField')}</span>
<select class="field-select" bind:value={sceneId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectScene')}</option>
{#each scenes as s}
{#each scenes as s (s.id)}
<option value={s.id}>#{s.order_index} {s.heading || t('attach.scene')}</option>
{/each}
</select>
@@ -229,6 +229,7 @@
{#if inputs.length === 0}
<p class="muted">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- This fixed key comes only from the seven bundled translation dictionaries, without interpolation or remote overrides. Retain the existing HTML-capable translation contract. -->
{@html t('cdf.noInputs')}
</p>
{:else}
@@ -381,7 +382,8 @@
}}
>
<option value="">{t('cdf.chooseAsset')}</option>
{#each assetOptions as opt}
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
{#each assetOptions as opt, i (i)}
<option value={opt.path}>{opt.label}</option>
{/each}
</select>
@@ -422,7 +424,8 @@
}}
>
<option value="">{t('cdf.chooseAsset')}</option>
{#each assetOptions as opt}
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
{#each assetOptions as opt, i (i)}
<option value={opt.path}>{opt.label}</option>
{/each}
</select>
@@ -453,7 +456,8 @@
}}
>
<option value="">{t('cdf.chooseAsset')}</option>
{#each assetOptions as opt}
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
{#each assetOptions as opt, i (i)}
<option value={opt.path}>{opt.label}</option>
{/each}
</select>
@@ -129,6 +129,7 @@
class="tool"
href={src}
download={downloadName}
rel="external"
aria-label={t('lightbox.downloadMedia')}
title={t('lightbox.download')}
onclick={(e) => e.stopPropagation()}
@@ -208,7 +209,7 @@
{:else}
{t('lightbox.clickToZoom')} · {t('lightbox.closeHint')} ·
{/if}
<a href={src} target="_blank" rel="noopener" onclick={(e) => e.stopPropagation()}
<a href={src} target="_blank" rel="noopener external" onclick={(e) => e.stopPropagation()}
>{t('lightbox.openOriginal')}</a
>
</p>
@@ -14,7 +14,7 @@
</script>
{#if segmented}<div class="mg-options" aria-label={t("common.language")}>
{#each languages as [code, label]}<button
{#each languages as [code, label] (code)}<button
class="mg-option"
class:on={language.current === code}
aria-pressed={language.current === code}
@@ -18,7 +18,7 @@
import { createUploadManager } from '$lib/comfy/useUpload.svelte';
import { normalizeInputRole } from '$lib/comfy/parser';
import type { AssetOption } from '$lib/assetPicker';
import { assetUrl } from '$lib/api';
import '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
import PillSelect from './omni/PillSelect.svelte';
@@ -48,7 +48,7 @@
onchanged,
}: Props = $props();
const client = useQueryClient();
useQueryClient();
let endpointId = $state('');
let values = $state<Record<string, string | number>>({});
@@ -146,7 +146,7 @@
if (stageLog[stageLog.length - 1] !== line) stageLog = [...stageLog, line];
});
$effect(() => {
stageLog.length;
void stageLog.length;
if (logBox) logBox.scrollTop = logBox.scrollHeight;
});
@@ -447,7 +447,7 @@
href={assetUrl(path)}
download
target="_blank"
rel="noreferrer"
rel="noreferrer external"
title={t('wft.download')}
>
<SafeMedia
@@ -61,6 +61,7 @@
/** Later tool results (wait_for_jobs) often have the finished status + paths. */
const trailJobsById = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Each derivation rebuilds this tool-result lookup from messages; only the completed Map escapes, with no later mutation.
const map = new Map<number, ArtifactJob>();
for (const row of messages) {
if (row.role !== 'tool' || !row.tool_result || typeof row.tool_result !== 'object') continue;
@@ -76,6 +76,7 @@ let {
void draftNonce;
if (draftNonce === 0) return;
if (!editorEl) return;
// eslint-disable-next-line svelte/no-dom-manipulating -- Template owns the empty editor shell; its user-editable subtree is imperative. Restore draft text before caret placement/focus and serialization.
editorEl.textContent = draft;
placeCaretAtEnd(editorEl);
editorEl.focus();
@@ -161,6 +162,7 @@ let {
attachments: [...attachments],
};
if (payloadIsEmpty(payload)) return;
// eslint-disable-next-line svelte/no-dom-manipulating -- Template does not render editor children; after serializing text/chips, clear the user-owned subtree before onSend, preserving submit/reset behavior.
if (editorEl) editorEl.innerHTML = '';
attachments = [];
mentionOpen = false;
@@ -291,8 +293,10 @@ let {
chip.after(space);
placeCaretAfter(space);
} else {
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor owns its children: fallback appends a noneditable workflow chip whose dataset serialize reads; keep template shell and caret logic intact.
editorEl.appendChild(makeChip(wf));
const space = document.createTextNode('\u00a0');
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor-owned trailing space provides a text caret after the workflow chip; placeCaretAfter uses this exact node.
editorEl.appendChild(space);
placeCaretAfter(space);
}
@@ -327,8 +331,10 @@ let {
chip.after(space);
placeCaretAfter(space);
} else {
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor owns its children: fallback appends a noneditable skill chip whose dataset serialize reads; keep template shell and caret logic intact.
editorEl.appendChild(makeSkillChip(skill));
const space = document.createTextNode('\u00a0');
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor-owned trailing space provides a text caret after the skill chip; placeCaretAfter uses this exact node.
editorEl.appendChild(space);
placeCaretAfter(space);
}
@@ -28,6 +28,7 @@
const sandbox = $derived(sessions.filter((s) => s.project_id == null));
const grouped = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Local grouping accumulator is consumed as an array in this derivation; sessions drive recomputation, not Map writes.
const map = new Map<number, { project: AgentSession['project']; items: AgentSession[] }>();
for (const s of sessions) {
if (s.project_id == null) continue;
@@ -1,16 +1,9 @@
<script lang="ts">
<script lang="ts" generics="Item extends { id?: number; name: string; kind?: string; description?: string | null }">
import { t } from '$lib/i18n.svelte';
interface MenuItem {
id?: number;
name: string;
kind?: string;
description?: string | null;
}
interface Props {
open: boolean;
items: MenuItem[];
items: Item[];
activeIndex: number;
/** Caret / `@` viewport box — menu opens above this (composer is bottom-docked). */
anchor: { top: number; bottom: number; left: number };
@@ -18,7 +11,7 @@
lockReason?: string | null;
/** Skill mode: rows show `/name` + a description subtitle, no kind chip. */
skillMode?: boolean;
onSelect: (item: any) => void;
onSelect: (item: Item) => void;
onHover: (index: number) => void;
}
@@ -62,6 +55,7 @@
if (!open || !menuEl) return;
document.body.appendChild(menuEl);
return () => {
// eslint-disable-next-line svelte/no-dom-manipulating -- Template creates menu contents; this effect moves the root to body and owns portal teardown on close/unmount. Remove that moved root to avoid residue.
menuEl?.remove();
};
});
@@ -7,68 +7,68 @@
* 不再使用彩色身份色;默认主循环智能体保持中性 --id-4。
*/
export const DEFAULT_AGENT_NAME = 'Calliope';
export const DEFAULT_AGENT_NAME = "Calliope";
// Fixed identity map (keyed by the raw agent_name the backend emits).
// Step numbers mirror the avatar five-set used across the app
// (models/agents/home project cards).
const IDENTITIES: Record<string, { label: string; color: string }> = {
planner: { label: 'Planner', color: 'var(--id-2)' },
'story-agent': { label: 'Story Agent', color: 'var(--id-3)' },
'script-agent': { label: 'Script Agent', color: 'var(--id-1)' },
'assets-agent': { label: 'Assets Agent', color: 'var(--id-4)' },
'video-agent': { label: 'Video Agent', color: 'var(--id-5)' },
'research-agent': { label: 'Research Agent', color: 'var(--id-4)' },
// The five named agents (HEL-613) — the runner stamps their zh names on
// named-session replies; steps mirror the avatar five-set.
剧本创作: { label: '剧本创作', color: 'var(--id-1)' },
视觉设计: { label: '视觉设计', color: 'var(--id-2)' },
分镜设计: { label: '分镜设计', color: 'var(--id-3)' },
素材资产: { label: '素材资产', color: 'var(--id-4)' },
镜头脚本: { label: '镜头脚本', color: 'var(--id-5)' },
planner: { label: "Planner", color: "var(--id-2)" },
"story-agent": { label: "Story Agent", color: "var(--id-3)" },
"script-agent": { label: "Script Agent", color: "var(--id-1)" },
"assets-agent": { label: "Assets Agent", color: "var(--id-4)" },
"video-agent": { label: "Video Agent", color: "var(--id-5)" },
"research-agent": { label: "Research Agent", color: "var(--id-4)" },
// The five named agents (HEL-613) — the runner stamps their zh names on
// named-session replies; steps mirror the avatar five-set.
剧本创作: { label: "剧本创作", color: "var(--id-1)" },
视觉设计: { label: "视觉设计", color: "var(--id-2)" },
分镜设计: { label: "分镜设计", color: "var(--id-3)" },
素材资产: { label: "素材资产", color: "var(--id-4)" },
镜头脚本: { label: "镜头脚本", color: "var(--id-5)" },
};
// Fallback palette for unknown names (stable hash → deterministic step),
// spanning the same five grayscale identity steps.
const FALLBACK_COLORS = [
'var(--id-1)',
'var(--id-2)',
'var(--id-3)',
'var(--id-4)',
'var(--id-5)',
"var(--id-1)",
"var(--id-2)",
"var(--id-3)",
"var(--id-4)",
"var(--id-5)",
];
function hashString(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) >>> 0;
}
return h;
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) >>> 0;
}
return h;
}
/** Neutral step (--id-4) — the default agent borrows no signal color. */
export const DEFAULT_AGENT_COLOR = 'var(--id-4)';
export const DEFAULT_AGENT_COLOR = "var(--id-4)";
export function agentDisplayName(name: string | null | undefined): string {
const key = (name || '').trim().toLowerCase();
if (!key) return DEFAULT_AGENT_NAME;
return IDENTITIES[key]?.label ?? prettyName(name!);
const key = (name || "").trim().toLowerCase();
if (!key) return DEFAULT_AGENT_NAME;
return IDENTITIES[key]?.label ?? prettyName(name!);
}
export function agentColor(name: string | null | undefined): string {
const key = (name || '').trim().toLowerCase();
if (!key) return DEFAULT_AGENT_COLOR;
const identity = IDENTITIES[key];
if (identity) return identity.color;
return FALLBACK_COLORS[hashString(key) % FALLBACK_COLORS.length];
const key = (name || "").trim().toLowerCase();
if (!key) return DEFAULT_AGENT_COLOR;
const identity = IDENTITIES[key];
if (identity) return identity.color;
return FALLBACK_COLORS[hashString(key) % FALLBACK_COLORS.length];
}
function prettyName(raw: string): string {
// "story-agent" → "Story Agent", "assets-agent" → "Assets Agent"
const title = raw
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
return title || DEFAULT_AGENT_NAME;
// "story-agent" → "Story Agent", "assets-agent" → "Assets Agent"
const title = raw
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
return title || DEFAULT_AGENT_NAME;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { untrack, type Snippet } from 'svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
@@ -71,13 +72,8 @@
job,
wf,
nowMs,
open,
inactive,
canSendToSlot,
crossfade,
videoEl = $bindable(null),
params,
onClose,
onReuse,
onRetry,
onCancel,
@@ -119,7 +115,6 @@
const output = $derived(
outputs[Math.min(outIndex, outputs.length - 1)] ?? null,
);
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
const video = $derived(!!job && !!output && isVideoOutput(job, output));
const wfDeleted = $derived(!!job && job.workflow_id == null);
const prompt = $derived(job ? promptOf(job, wf) : '');
@@ -305,7 +300,8 @@
role="group"
aria-label="本次生成的作品"
>
{#each outputs as p, i}<button
<!-- Output paths may repeat; the button number and outIndex refer to this position. -->
{#each outputs as p, i (i)}<button
aria-pressed={output === p}
onclick={() => (outIndex = i)}
aria-label={'查看第 ' + (i + 1) + ' 件作品'}>{i + 1}</button
@@ -327,6 +323,7 @@
href={output ? assetUrl(output) : undefined}
aria-disabled={!output}
download
rel="external"
aria-label="下载生成结果"><Icon name="download" size={14} />下载</a
>
<span class="action-separator" aria-hidden="true"></span>
@@ -360,7 +357,7 @@
: '通用选择项目与阶段将在后续批次提供'}
onclick={onAttach}><Icon name="folder" size={14} />存入项目</button
>
<a class="canvas-btn" href="/assets"
<a class="canvas-btn" href={resolve('/library')}
><Icon name="assets" size={14} />素材库</a
>
<button
@@ -458,7 +455,8 @@
{fileGone ? t('jobst.missingDetail') : st.detail}
</p>{/if}
<div class="detail-meta">
{#each spec as s}<span>{s.label} · {s.value}</span
<!-- Display-only spec rows have no local state; preserve their existing positional reuse. -->
{#each spec as s, i (i)}<span>{s.label} · {s.value}</span
>{/each}{#if endpoint}<span>{endpoint}</span>{/if}
</div>
{#if inputs.length || prompt}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { isWorkflowRunnable } from '$lib/workflowState';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
@@ -139,7 +140,7 @@
}
function choose(m: WorkflowMeta) {
if (runnable(m)) onPick(m.id);
else void goto('/workflows?manage=1&wf=' + m.id);
else void goto(resolve(`/workflows?manage=1&wf=${m.id}`));
}
function pin(id: number) {
pins = pins.includes(id) ? pins.filter((p) => p !== id) : [...pins, id];
@@ -205,7 +206,7 @@
<button aria-pressed={usage === 'all'} onclick={() => (usage = 'all')}
>全部</button
>
{#each usages as u}<button
{#each usages as u (u)}<button
aria-pressed={usage === u}
onclick={() => (usage = u!)}>{usageLabel(u!)}</button
>{/each}
@@ -221,7 +222,7 @@
<button aria-pressed={family === 'all'} onclick={() => (family = 'all')}
>全部模型</button
>
{#each families as f}<button
{#each families as f (f)}<button
aria-pressed={family === f}
onclick={() => (family = f!)}>{f}</button
>{/each}
@@ -288,13 +289,13 @@
>{/if}
</div>
<footer>
{#if pendingCount}<a href="/workflows?manage=1"
{#if pendingCount}<a href={resolve('/workflows?manage=1')}
>{pendingCount} 个工作流待配置 →</a
>{/if}<button class="manage" data-workflow-details onclick={onDetails}
><span>+</span>
<div>工作流说明与管理<small>输入要求 / 导入 / 分类</small></div>
<b>›</b></button
><a href="/models">云端模型与接口 ↗</a>
><a href={resolve('/models')}>云端模型与接口 ↗</a>
</footer>
</aside>
@@ -1,14 +1,16 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createHref } from '$lib/createHref';
import { isWorkflowRunnable } from '$lib/workflowState';
import { readDeskDraft, writeDeskDraft } from './draftCache';
import HistoryRail from './HistoryRail.svelte';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { tick, untrack } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet, SvelteURL } from 'svelte/reactivity';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import Icon from '$lib/components/ui/Icon.svelte';
import '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import AppShell from '$lib/components/shell/AppShell.svelte';
import Composer from '$lib/components/generate/Composer.svelte';
@@ -39,7 +41,7 @@
workbenchCtxOf,
type FeedFilter,
} from '$lib/components/generate/takes';
import InputRequirementBadge from './InputRequirementBadge.svelte';
import './InputRequirementBadge.svelte';
import WorkflowBay from './WorkflowBay.svelte';
import DeskWorkflowRail from './DeskWorkflowRail.svelte';
import {
@@ -134,6 +136,7 @@
// Full records, lazily fetched per id (selected workflow, feed jobs'
// workflows, the bay's preview row) and cached until invalidated.
const details = new SvelteMap<number, Workflow>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Nonreactive request gate: ensureDetail reads has synchronously in the effect; reactive delete on failure would re-arm requests without a cached detail.
const inflight = new Set<number>();
async function ensureDetail(id: number): Promise<Workflow | null> {
@@ -153,6 +156,7 @@
}
const neededIds = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Rebuilt from workflowId, bay selection and jobs on each derivation; this completed ID set is never mutated by consumers.
const ids = new Set<number>();
if (workflowId != null) ids.add(workflowId);
if (bayOpen && bayActiveId != null) ids.add(bayActiveId);
@@ -197,7 +201,7 @@
// Jobs enqueued from this desk — a fast failure can land before the
// jobs list refetch knows about them.
const submittedIds = new Set<number>();
const submittedIds = new SvelteSet<number>();
// D 批: one-shot completion flash / failure shake (SSE-driven) and the
// 5-second undo window for record deletes.
@@ -252,7 +256,7 @@
// Bay (挑选台) state — mode, deep links, open/collapse
// ------------------------------------------------------------------
type TaskMode = WorkflowUsage | 'all';
let mode = $state<TaskMode>(untrack(initMode));
const mode = $state<TaskMode>(untrack(initMode));
function initMode(): TaskMode {
const q = page.url.searchParams.get('mode');
return q && USAGE_BY_KIND[kind].includes(q as WorkflowUsage)
@@ -322,15 +326,6 @@
}
});
async function toggleFavorite(id: number, fav: boolean) {
try {
await workflows.update(id, { favorite: fav });
await client.invalidateQueries({ queryKey: ['workflows-meta'] });
await client.invalidateQueries({ queryKey: ['workflows'] });
} catch (err) {
toast.error(apiErrorMessage(err));
}
}
// ------------------------------------------------------------------
// Form state
@@ -724,8 +719,6 @@
sentTimer = setTimeout(() => (sent = false), 900);
// C 批审核:从「参数」页提交后自动切到「结果」页,进行中进度可见。
tab = 'result';
if (wide) inspOff = false;
else if (!narrow) inspOpen = true;
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
// 最近使用由后端在入队时记录 (queue/manager);这里只刷新轻量列表。
@@ -747,28 +740,9 @@
let historyJobId = $state<number | null>(null);
let busyId = $state<number | null>(null);
let tab = $state<'result' | 'params'>('result');
let inspOff = $state(false);
let inspOpen = $state(false);
let wide = $state(true);
let narrow = $state(false);
let crossfade = $state(false);
let videoEl = $state<HTMLVideoElement | null>(null);
$effect(() => {
const mq = window.matchMedia('(min-width: 1180px)');
const mq2 = window.matchMedia('(max-width: 760px)');
const sync = () => {
wide = mq.matches;
narrow = mq2.matches;
};
sync();
mq.addEventListener('change', sync);
mq2.addEventListener('change', sync);
return () => {
mq.removeEventListener('change', sync);
mq2.removeEventListener('change', sync);
};
});
let nowMs = $state(Date.now());
const anyActive = $derived(
@@ -807,7 +781,6 @@
tab = 'result';
crossfade = via === 'pointer';
if (via === 'pointer') {
if (!wide) inspOpen = true;
void tick().then(() => feed?.slideFrom(from));
} else {
feed?.scrollToJob(job.id);
@@ -877,12 +850,12 @@
);
function mediumLink(next: DeskKind) {
if (slotBusy || submitting) return;
const url = new URL(page.url);
const url = new SvelteURL(page.url);
url.pathname = '/create';
url.searchParams.set('kind', next);
url.searchParams.delete('wf');
url.searchParams.delete('mode');
goto(url.pathname + url.search + url.hash);
goto(resolve(createHref(url)));
}
const imageSlotTarget = $derived(
@@ -976,7 +949,7 @@
const url = new URL(page.url);
url.searchParams.set('kind', wf.kind);
url.searchParams.set('wf', String(wf.id));
await goto('/create' + url.search + url.hash);
await goto(resolve(createHref(url)));
return;
}
if (lastWf) workflowEndpoints.set(lastWf.id, endpointId);
@@ -1111,14 +1084,6 @@
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦搜索/提示词 · ↑↓/J/K 切换
// Ctrl/⌘+Enter 仅在创作输入区提交;Esc 退出选工具。媒体与记录使用显式控件。
// ------------------------------------------------------------------
function typing(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
return (
!!el &&
(el.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))
);
}
function inLayer(target: EventTarget | null): boolean {
const el = target instanceof Element ? target : null;
@@ -1393,9 +1358,9 @@
<ul>
{#if inputs.prompt}<li>
画面描述 · {inputs.prompt.required ? '必填' : '可选'}
</li>{/if}{#each inputs.slots as slot}<li>
</li>{/if}{#each inputs.slots as slot (slot.inp.nodeId)}<li>
{slot.label} · {slot.inp.required ? '必填' : '可选'}
</li>{/each}{#each inputs.lists as list}<li>
</li>{/each}{#each inputs.lists as list (list.nodeId)}<li>
{list.label} · {list.required ? '必填' : '可选'}{list.max
? ',最多 ' + list.max + ' 个'
: ''}
@@ -1486,9 +1451,9 @@
id="comfy-endpoint"
bind:value={paramEndpoint}
>{#if !endpoints.length}<option value="">尚未配置服务</option
>{/if}{#each endpoints as ep}<option value={ep.id}>{ep.name}</option
>{/if}{#each endpoints as ep (ep.id)}<option value={ep.id}>{ep.name}</option
>{/each}</select
><a href="/connections">配置或检查连接 ↗</a>
><a href={resolve('/connections')}>配置或检查连接 ↗</a>
</div>
{@render paramsTab()}
</div>
@@ -18,11 +18,8 @@
let { kind, metas, active, onSelect, onPickWorkflow }: Props = $props();
interface ModeEntry {
usage: WorkflowUsage;
count: number;
}
const modes = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Local counting accumulator is consumed synchronously into mode rows; metas/kind drive the derivation.
const counts = new Map<WorkflowUsage, number>();
for (const m of metas) {
if (!m.usage) continue;
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { isWorkflowRunnable } from '$lib/workflowState';
import { tick } from 'svelte';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -26,7 +27,6 @@
pendingCount,
selectedId,
activeId = $bindable(null),
endpoints,
onPick,
onClose,
catalogue = false,
@@ -174,7 +174,7 @@
recentOnly = false;
}}><span>全部工具</span><small>{metas.length}</small></button
>
{#each categories as cat}<button
{#each categories as cat (cat)}<button
class:active={category === cat}
onclick={() => {
category = cat;
@@ -218,9 +218,9 @@
>{/if}{#if source === 'local'}<select aria-label="筛选创作任务" bind:value={usage}
><option value="all">所有创作任务</option>{#each [...new Set(metas
.map((m) => m.usage)
.filter(Boolean))] as u}<option value={u!}>{usageLabel(u!)}</option>{/each}</select
.filter(Boolean))] as u (u)}<option value={u!}>{usageLabel(u!)}</option>{/each}</select
><select aria-label="筛选模型家族" bind:value={family}
><option value="all">全部模型</option>{#each families as f}<option value={f}>{f}</option
><option value="all">全部模型</option>{#each families as f (f)}<option value={f}>{f}</option
>{/each}</select
>{/if}<span>{source === 'local' ? filtered.length : cloudModels.length} 个结果</span>
</div>
@@ -229,14 +229,14 @@
<div class="cloud-context">
<h3>让专业搭档加入创作。</h3>
<p>使用已配置模型,在智能体或画布中创作。自定义多媒体接口与技能可在设置中管理。</p>
<a href="/canvas" class="a-btn primary">打开创作画布 ↗</a>
<a href={resolve('/canvas')} class="a-btn primary">打开创作画布 ↗</a>
</div>
{#each cloudModels as profile}<article class="workflow-card">
{#each cloudModels as profile (profile.id)}<article class="workflow-card">
<div class="family">{profile.model}<span>已配置模型</span></div>
<h3>{profile.name}</h3>
<a href="/agents" class="tool-link">交给智能体创作 ↗</a>
<a href={resolve('/agents')} class="tool-link">交给智能体创作 ↗</a>
</article>{:else}<p class="empty">
还没有匹配的模型。<a href="/models">配置模型 ↗</a>
还没有匹配的模型。<a href={resolve('/models')}>配置模型 ↗</a>
</p>{/each}
{:else}
{#each visible as m (m.id)}<article
@@ -273,7 +273,7 @@
onclick={() => favorite(m)}>{m.favorite ? '★' : '☆'}</button
>
{#if m.needs_mapping || !m.is_enabled || !m.usage}<a
href="/workflows?manage=1"
href={resolve('/workflows?manage=1')}
class="tool-link">{m.needs_mapping ? '完成映射' : '管理工作流'} ↗</a
>{/if}
</article>{:else}<p class="empty">没有找到匹配的工作流,试试更短的关键词。</p>{/each}
@@ -285,7 +285,7 @@
{/if}
</div>
{#if pendingCount > 0}<p class="pending">
另有 {pendingCount} 个工作流待映射。<a href="/workflows?manage=1">去整理 ↗</a>
另有 {pendingCount} 个工作流待映射。<a href={resolve('/workflows?manage=1')}>去整理 ↗</a>
</p>{/if}
</section>
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Modal from '$lib/components/ui/Modal.svelte';
import { tick } from 'svelte';
import { assetUrl } from '$lib/api';
@@ -63,7 +64,6 @@
slotBusy = $bindable(false),
promptEl = $bindable(null),
onSet,
onOpenBay,
onPickEndpoint,
onSubmit,
onReveal,
@@ -177,7 +177,7 @@
</div>{/if}
{#if dock}<div class="media-summary" class:has-slots={hasSlots}>
{#if hasSlots}
{#each ds.slots.slice(0, 3) as slot}<button
{#each ds.slots.slice(0, 3) as slot (slot.inp.nodeId)}<button
class="media-token"
onclick={openMedia}
aria-label={'设置' + slot.label}
@@ -313,9 +313,9 @@
onchange={(e) => onPickEndpoint(e.currentTarget.value)}
>
{#if !endpoints.length}<option value="">尚未配置服务</option>{/if}
{#each endpoints as ep}<option value={ep.id}>{ep.name}</option
{#each endpoints as ep (ep.id)}<option value={ep.id}>{ep.name}</option
>{/each}
</select><a href="/connections" target="_blank" rel="noreferrer"
</select><a href={resolve('/connections')} target="_blank" rel="noreferrer"
>配置或检查连接 ↗</a
>
</details>{/if}
@@ -326,7 +326,7 @@
>✦ {endpointName}⌄</button
>{#each commonInputs
.filter((i) => i !== ds.height)
.slice(0, 3) as inp}<button
.slice(0, 3) as inp (inp.nodeId)}<button
class="parameter"
onclick={(e) => openParameter(inp, e.currentTarget)}
>{inp.label}: {String(
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
import { backHref, type DeskContext } from './context';
@@ -29,7 +30,7 @@
<span class="note">
{canFlowBack ? t('desk.ctxBackfill', { stage: stageName }) : t('desk.ctxNoBackfill', { stage: stageName })}
</span>
<a class="a-btn ghost xs back" href={backHref(ctx)}>{t('desk.ctxBack', { stage: stageName })}</a>
<a class="a-btn ghost xs back" href={resolve(backHref(ctx))}>{t('desk.ctxBack', { stage: stageName })}</a>
<button type="button" class="a-btn ghost xs" aria-label={t('desk.ctxDismiss')} onclick={onDismiss}>
<Icon name="close" size={12} />
</button>
@@ -62,7 +62,6 @@
open,
inactive,
canSendToSlot,
crossfade,
videoEl = $bindable(null),
params,
onClose,
@@ -337,7 +336,7 @@
</button>
{/if}
{#if output}
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
<a class="a-btn secondary sm" href={assetUrl(output)} download rel="external">{t('desk.download')}</a>
{/if}
{#if output && video}
<button
@@ -116,7 +116,9 @@
/** Composer-wide drop: each file goes to the first wired slot of its kind with room. */
export function dropFiles(files: File[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Per-drop allocation buffer, synchronously consumed into groupAdd calls; no collection state survives this invocation.
const pending = new Map<string, File[]>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Per-drop reservation set prevents assigning two files to one slot before async upload completes; never used as render state.
const taken = new Set<string>();
let unplaced = 0;
for (const f of files) {
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import type { Job, Workflow } from '$lib/comfy/types';
@@ -205,7 +206,7 @@
<span class="a-tag ok" title={t('desk.attachHint', { label: wbCtx.label ?? '' })}>
{t('desk.attachedTag')}
</span>
<a class="a-btn ghost xs" href={`/workbench/${wbCtx.projectId}?stage=${wbCtx.stage}`}>
<a class="a-btn ghost xs" href={resolve(`/workbench/${wbCtx.projectId}?stage=${wbCtx.stage}`)}>
{t('desk.attachBack')}
</a>
{/if}
@@ -217,7 +218,7 @@
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
{/if}
{#if output}
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
<a class="a-btn ghost xs" href={assetUrl(output)} download rel="external">{t('desk.download')}</a>
{/if}
{#if !active}
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
@@ -125,6 +125,7 @@
// Entrance bookkeeping: only takes that appear AFTER the first fill
// (or come back from an undo) play the 220ms rise.
// ------------------------------------------------------------------
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Nonreactive entrance ledger read/written in the jobs effect and markEnter; keep bookkeeping outside effect dependencies, with animateIds driving classes.
const seenIds = new Set<number>();
const animateIds = new SvelteSet<number>();
let hydrated = false;
@@ -1,5 +1,5 @@
import type { WorkbenchStageKey } from '$lib/api';
import type { DeskKind } from './schema';
import type { WorkbenchStageKey } from "$lib/api";
import type { DeskKind } from "./schema";
/**
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
@@ -8,100 +8,119 @@ import type { DeskKind } from './schema';
* finished take can be flowed back to its source row (HEL-684).
*/
export interface DeskContext {
kind: DeskKind;
projectId: number;
stage: WorkbenchStageKey;
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
label: string;
prompt?: string;
negative?: string;
durationSec?: number;
/** Real source row identity — only present on links the workbench
* stamped with it; without it no flow-back is offered (nothing guessed). */
sourceKind?: 'asset' | 'shot';
sourceId?: string;
kind: DeskKind;
projectId: number;
stage: WorkbenchStageKey;
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
label: string;
prompt?: string;
negative?: string;
durationSec?: number;
/** Real source row identity — only present on links the workbench
* stamped with it; without it no flow-back is offered (nothing guessed). */
sourceKind?: "asset" | "shot";
sourceId?: string;
}
/** The generate-request context payload shape (HEL-684). */
export interface DeskWorkbenchContext {
project_id: number;
stage: 'assets' | 'shot';
source_kind: 'asset' | 'shot';
source_id: string;
label?: string;
project_id: number;
stage: "assets" | "shot";
source_kind: "asset" | "shot";
source_id: string;
label?: string;
}
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
const STAGES: WorkbenchStageKey[] = [
"script",
"style",
"storyboard",
"assets",
"shot",
];
/** Stage ↔ desk kind pairing the workbench rows render (assets → image,
* shot → video). Anything else cannot display a flowed-back take. */
const KIND_BY_STAGE: Partial<Record<WorkbenchStageKey, DeskKind>> = {
assets: 'image',
shot: 'video',
assets: "image",
shot: "video",
};
export function deskHref(ctx: DeskContext): string {
const q = new URLSearchParams({
from: 'workbench',
project: String(ctx.projectId),
stage: ctx.stage,
label: ctx.label,
});
if (ctx.prompt) q.set('prompt', ctx.prompt);
if (ctx.negative) q.set('negative', ctx.negative);
if (ctx.durationSec != null) q.set('duration', String(ctx.durationSec));
if (ctx.sourceKind && ctx.sourceId) {
q.set('source_kind', ctx.sourceKind);
q.set('source_id', ctx.sourceId);
}
return `/${ctx.kind}?${q.toString()}`;
export function deskHref(ctx: DeskContext): `/${DeskKind}?${string}` {
const q = new URLSearchParams({
from: "workbench",
project: String(ctx.projectId),
stage: ctx.stage,
label: ctx.label,
});
if (ctx.prompt) q.set("prompt", ctx.prompt);
if (ctx.negative) q.set("negative", ctx.negative);
if (ctx.durationSec != null) q.set("duration", String(ctx.durationSec));
if (ctx.sourceKind && ctx.sourceId) {
q.set("source_kind", ctx.sourceKind);
q.set("source_id", ctx.sourceId);
}
return `/${ctx.kind}?${q.toString()}`;
}
export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
const q = url.searchParams;
if (q.get('from') !== 'workbench') return null;
const projectId = Number(q.get('project'));
const stage = q.get('stage') as WorkbenchStageKey | null;
if (!Number.isInteger(projectId) || projectId <= 0 || !stage || !STAGES.includes(stage)) return null;
const duration = Number(q.get('duration'));
const rawKind = q.get('source_kind');
const rawId = q.get('source_id') ?? '';
// Source identity is only kept when it is complete and self-consistent;
// old/hand-made links keep today's prefill-only behavior.
const expectedKind = stage === 'assets' ? 'asset' : stage === 'shot' ? 'shot' : null;
const sourceKind = rawKind === 'asset' || rawKind === 'shot' ? rawKind : null;
const hasSource = sourceKind !== null && sourceKind === expectedKind && rawId.trim() !== '';
return {
kind,
projectId,
stage,
label: q.get('label') ?? '',
prompt: q.get('prompt') ?? undefined,
negative: q.get('negative') ?? undefined,
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
sourceKind: hasSource ? (sourceKind as 'asset' | 'shot') : undefined,
sourceId: hasSource ? rawId : undefined,
};
const q = url.searchParams;
if (q.get("from") !== "workbench") return null;
const projectId = Number(q.get("project"));
const stage = q.get("stage") as WorkbenchStageKey | null;
if (
!Number.isInteger(projectId) ||
projectId <= 0 ||
!stage ||
!STAGES.includes(stage)
)
return null;
const duration = Number(q.get("duration"));
const rawKind = q.get("source_kind");
const rawId = q.get("source_id") ?? "";
// Source identity is only kept when it is complete and self-consistent;
// old/hand-made links keep today's prefill-only behavior.
const expectedKind =
stage === "assets" ? "asset" : stage === "shot" ? "shot" : null;
const sourceKind = rawKind === "asset" || rawKind === "shot" ? rawKind : null;
const hasSource =
sourceKind !== null && sourceKind === expectedKind && rawId.trim() !== "";
return {
kind,
projectId,
stage,
label: q.get("label") ?? "",
prompt: q.get("prompt") ?? undefined,
negative: q.get("negative") ?? undefined,
durationSec:
Number.isFinite(duration) && duration > 0 ? duration : undefined,
sourceKind: hasSource ? (sourceKind as "asset" | "shot") : undefined,
sourceId: hasSource ? rawId : undefined,
};
}
/**
* The context a generate request carries: only when the full, real source
* identity is present AND the desk kind matches what that stage renders.
*/
export function workbenchContextOf(ctx: DeskContext | null): DeskWorkbenchContext | undefined {
if (!ctx?.sourceId || !ctx.sourceKind) return undefined;
if (ctx.stage !== 'assets' && ctx.stage !== 'shot') return undefined;
if (KIND_BY_STAGE[ctx.stage] !== ctx.kind) return undefined;
const out: DeskWorkbenchContext = {
project_id: ctx.projectId,
stage: ctx.stage,
source_kind: ctx.sourceKind,
source_id: ctx.sourceId,
};
if (ctx.label.trim()) out.label = ctx.label;
return out;
export function workbenchContextOf(
ctx: DeskContext | null,
): DeskWorkbenchContext | undefined {
if (!ctx?.sourceId || !ctx.sourceKind) return undefined;
if (ctx.stage !== "assets" && ctx.stage !== "shot") return undefined;
if (KIND_BY_STAGE[ctx.stage] !== ctx.kind) return undefined;
const out: DeskWorkbenchContext = {
project_id: ctx.projectId,
stage: ctx.stage,
source_kind: ctx.sourceKind,
source_id: ctx.sourceId,
};
if (ctx.label.trim()) out.label = ctx.label;
return out;
}
export function backHref(ctx: DeskContext): string {
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
export function backHref(
ctx: DeskContext,
): `/workbench/${string}?stage=${string}` {
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
}
+396 -281
View File
@@ -1,7 +1,11 @@
import type { ComfyDynamicInput, Workflow, WorkflowUsage } from '$lib/comfy/types';
import { normalizeInputRole } from '$lib/comfy/parser';
import { ratioShort, resolutionSelectorSize } from '$lib/comfy/resolution';
import { t } from '$lib/i18n.svelte';
import type {
ComfyDynamicInput,
Workflow,
WorkflowUsage,
} from "$lib/comfy/types";
import { normalizeInputRole } from "$lib/comfy/parser";
import { ratioShort, resolutionSelectorSize } from "$lib/comfy/resolution";
import { t } from "$lib/i18n.svelte";
/**
* 生成台表单识别 (HEL-653, moved verbatim from StudioGenerate): slots and
@@ -9,366 +13,477 @@ import { t } from '$lib/i18n.svelte';
* never from model names. No wiring → no slot.
*/
export type DeskKind = 'image' | 'video';
export type DeskKind = "image" | "video";
export type FormValue = string | number | string[];
export type FormValues = Record<string, FormValue>;
export interface SlotDef {
inp: ComfyDynamicInput;
label: string;
mediaKind: 'image' | 'video' | 'audio';
fromResults: boolean;
/** Required-slot message when left empty (ref-upload-spec §3). */
missing: string;
firstFrame: boolean;
lastFrame: boolean;
mask: boolean;
inp: ComfyDynamicInput;
label: string;
mediaKind: "image" | "video" | "audio";
fromResults: boolean;
/** Required-slot message when left empty (ref-upload-spec §3). */
missing: string;
firstFrame: boolean;
lastFrame: boolean;
mask: boolean;
}
/** Why 生成 is disabled — the line next to the button (spec-v2 §1.2). */
export interface BlockReason {
text: string;
tone: 'warn' | 'err';
/** Element id to focus when the reason is clicked. */
target?: string;
/** Open the params tab instead of focusing. */
params?: boolean;
text: string;
tone: "warn" | "err";
/** Element id to focus when the reason is clicked. */
target?: string;
/** Open the params tab instead of focusing. */
params?: boolean;
}
export interface DeskSchema {
prompt: ComfyDynamicInput | null;
negative: ComfyDynamicInput | null;
seed: ComfyDynamicInput | null;
width: ComfyDynamicInput | null;
height: ComfyDynamicInput | null;
resolution: ComfyDynamicInput | null;
duration: ComfyDynamicInput | null;
batch: ComfyDynamicInput | null;
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
ratio: ComfyDynamicInput | null;
megapixels: ComfyDynamicInput | null;
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
slots: SlotDef[];
/** Autogrow media lists actually wired (image / video / audio). */
lists: ComfyDynamicInput[];
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
common: ComfyDynamicInput[];
/** Everything else that is not media and not the prompt — folded. */
advanced: ComfyDynamicInput[];
hasH3Hint: boolean;
prompt: ComfyDynamicInput | null;
negative: ComfyDynamicInput | null;
seed: ComfyDynamicInput | null;
width: ComfyDynamicInput | null;
height: ComfyDynamicInput | null;
resolution: ComfyDynamicInput | null;
duration: ComfyDynamicInput | null;
batch: ComfyDynamicInput | null;
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
ratio: ComfyDynamicInput | null;
megapixels: ComfyDynamicInput | null;
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
slots: SlotDef[];
/** Autogrow media lists actually wired (image / video / audio). */
lists: ComfyDynamicInput[];
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
common: ComfyDynamicInput[];
/** Everything else that is not media and not the prompt — folded. */
advanced: ComfyDynamicInput[];
hasH3Hint: boolean;
}
export function inputRole(inp: ComfyDynamicInput): string | null {
return normalizeInputRole(inp.role ?? null);
return normalizeInputRole(inp.role ?? null);
}
function hay(inp: ComfyDynamicInput): string {
return `${inp.role ?? ''} ${inp.label}`.toLowerCase();
return `${inp.role ?? ""} ${inp.label}`.toLowerCase();
}
export function isMedia(inp: ComfyDynamicInput): boolean {
return (
inp.kind === 'image' ||
inp.kind === 'image_url' ||
inp.kind === 'video' ||
inp.kind === 'audio' ||
inp.kind === 'image_list' ||
inp.kind === 'video_list' ||
inp.kind === 'audio_list'
);
return (
inp.kind === "image" ||
inp.kind === "image_url" ||
inp.kind === "video" ||
inp.kind === "audio" ||
inp.kind === "image_list" ||
inp.kind === "video_list" ||
inp.kind === "audio_list"
);
}
function isList(inp: ComfyDynamicInput): boolean {
return inp.kind === 'image_list' || inp.kind === 'video_list' || inp.kind === 'audio_list';
return (
inp.kind === "image_list" ||
inp.kind === "video_list" ||
inp.kind === "audio_list"
);
}
function isMask(inp: ComfyDynamicInput): boolean {
return inputRole(inp) === 'mask' || /mask|遮罩/.test(hay(inp));
return inputRole(inp) === "mask" || /mask|遮罩/.test(hay(inp));
}
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
// workflows still name frames only in the title.
export function isFirstFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
return inputRole(inp) === 'first_frame' || (!inp.auto && kind === 'video' && /first|start|首帧/.test(hay(inp)));
return (
inputRole(inp) === "first_frame" ||
(!inp.auto && kind === "video" && /first|start|首帧/.test(hay(inp)))
);
}
export function isLastFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
return inputRole(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
return (
inputRole(inp) === "last_frame" ||
(!inp.auto && kind === "video" && /last|end|尾帧/.test(hay(inp)))
);
}
export function listKind(entry: ComfyDynamicInput): 'image' | 'video' | 'audio' {
if (entry.kind === 'video_list') return 'video';
if (entry.kind === 'audio_list') return 'audio';
return 'image';
export function listKind(
entry: ComfyDynamicInput,
): "image" | "video" | "audio" {
if (entry.kind === "video_list") return "video";
if (entry.kind === "audio_list") return "audio";
return "image";
}
export function listPaths(values: FormValues, entry: ComfyDynamicInput): string[] {
const v = values[entry.nodeId];
if (Array.isArray(v)) return v;
return typeof v === 'string' && v ? [v] : [];
export function listPaths(
values: FormValues,
entry: ComfyDynamicInput,
): string[] {
const v = values[entry.nodeId];
if (Array.isArray(v)) return v;
return typeof v === "string" && v ? [v] : [];
}
export function classifySchema(schema: ComfyDynamicInput[], kind: DeskKind): DeskSchema {
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
const byRole = (r: string) => plain((i) => inputRole(i) === r);
export function classifySchema(
schema: ComfyDynamicInput[],
kind: DeskKind,
): DeskSchema {
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
const byRole = (r: string) => plain((i) => inputRole(i) === r);
const prompt = byRole('prompt');
const negative = byRole('negative');
const seed = byRole('seed');
const width = byRole('width');
const height = byRole('height');
const resolution = byRole('resolution');
const duration = byRole('duration');
const batch = plain((i) => /批次|batch/.test(hay(i)));
const ratio =
schema.find((i) => i.kind === 'select' && inputRole(i) === 'aspect_ratio' && i.resolution) ?? null;
const megapixels = ratio ? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null) : null;
const prompt = byRole("prompt");
const negative = byRole("negative");
const seed = byRole("seed");
const width = byRole("width");
const height = byRole("height");
const resolution = byRole("resolution");
const duration = byRole("duration");
const batch = plain((i) => /批次|batch/.test(hay(i)));
const ratio =
schema.find(
(i) =>
i.kind === "select" && inputRole(i) === "aspect_ratio" && i.resolution,
) ?? null;
const megapixels = ratio
? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null)
: null;
const media = schema.filter((i) => isMedia(i) && !isList(i));
const plainImages = media.filter(
(i) => i.kind === 'image' && !isMask(i) && !isFirstFrame(i, kind) && !isLastFrame(i, kind),
);
const editInput = kind === 'image' && plainImages.length === 1 ? plainImages[0] : null;
const slots: SlotDef[] = [];
let refIdx = 0;
for (const inp of media) {
const base = { inp, missing: '', firstFrame: false, lastFrame: false, mask: false };
if (inp.kind === 'video') {
slots.push({ ...base, label: t('studio.slotRefVideo'), mediaKind: 'video', fromResults: false });
} else if (inp.kind === 'audio') {
slots.push({ ...base, label: t('studio.slotAudio'), mediaKind: 'audio', fromResults: false });
} else if (isMask(inp)) {
slots.push({ ...base, mask: true, label: t('studio.slotMask'), mediaKind: 'image', fromResults: false });
} else if (isFirstFrame(inp, kind)) {
slots.push({
...base,
firstFrame: true,
label: t('studio.slotFirst'),
mediaKind: 'image',
fromResults: true,
missing: t('studio.errNeedFirstFrame'),
});
} else if (isLastFrame(inp, kind)) {
slots.push({ ...base, lastFrame: true, label: t('studio.slotLast'), mediaKind: 'image', fromResults: true });
} else if (inp === editInput) {
slots.push({
...base,
label: t('studio.slotInput'),
mediaKind: 'image',
fromResults: true,
missing: t('studio.errNeedInputImage'),
});
} else {
refIdx += 1;
const label = inp.auto
? inp.label
: refIdx === 1 && media.length === 1
? t('studio.slotRef')
: t('studio.slotRefN', { n: refIdx });
slots.push({
...base,
label,
mediaKind: 'image',
fromResults: true,
missing: t('studio.errUpload', { label }),
});
}
}
const media = schema.filter((i) => isMedia(i) && !isList(i));
const plainImages = media.filter(
(i) =>
i.kind === "image" &&
!isMask(i) &&
!isFirstFrame(i, kind) &&
!isLastFrame(i, kind),
);
const editInput =
kind === "image" && plainImages.length === 1 ? plainImages[0] : null;
const slots: SlotDef[] = [];
let refIdx = 0;
for (const inp of media) {
const base = {
inp,
missing: "",
firstFrame: false,
lastFrame: false,
mask: false,
};
if (inp.kind === "video") {
slots.push({
...base,
label: t("studio.slotRefVideo"),
mediaKind: "video",
fromResults: false,
});
} else if (inp.kind === "audio") {
slots.push({
...base,
label: t("studio.slotAudio"),
mediaKind: "audio",
fromResults: false,
});
} else if (isMask(inp)) {
slots.push({
...base,
mask: true,
label: t("studio.slotMask"),
mediaKind: "image",
fromResults: false,
});
} else if (isFirstFrame(inp, kind)) {
slots.push({
...base,
firstFrame: true,
label: t("studio.slotFirst"),
mediaKind: "image",
fromResults: true,
missing: t("studio.errNeedFirstFrame"),
});
} else if (isLastFrame(inp, kind)) {
slots.push({
...base,
lastFrame: true,
label: t("studio.slotLast"),
mediaKind: "image",
fromResults: true,
});
} else if (inp === editInput) {
slots.push({
...base,
label: t("studio.slotInput"),
mediaKind: "image",
fromResults: true,
missing: t("studio.errNeedInputImage"),
});
} else {
refIdx += 1;
const label = inp.auto
? inp.label
: refIdx === 1 && media.length === 1
? t("studio.slotRef")
: t("studio.slotRefN", { n: refIdx });
slots.push({
...base,
label,
mediaKind: "image",
fromResults: true,
missing: t("studio.errUpload", { label }),
});
}
}
const lists = schema.filter(isList);
const common = [ratio, width, height, resolution, duration, seed].filter(
(i): i is ComfyDynamicInput => !!i,
);
const commonIds = new Set(common.map((i) => i.nodeId));
const advanced = schema.filter((i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId));
const lists = schema.filter(isList);
const common = [ratio, width, height, resolution, duration, seed].filter(
(i): i is ComfyDynamicInput => !!i,
);
const commonIds = new Set(common.map((i) => i.nodeId));
const advanced = schema.filter(
(i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId),
);
return {
prompt,
negative,
seed,
width,
height,
resolution,
duration,
batch,
ratio,
megapixels,
slots,
lists,
common,
advanced,
hasH3Hint: lists.some((e) => e.media_hint === 'minimax_h3_ref2va'),
};
return {
prompt,
negative,
seed,
width,
height,
resolution,
duration,
batch,
ratio,
megapixels,
slots,
lists,
common,
advanced,
hasH3Hint: lists.some((e) => e.media_hint === "minimax_h3_ref2va"),
};
}
/** Workflow JSON defaults — the 原值 every parameter falls back to. */
export function schemaDefaults(schema: ComfyDynamicInput[]): FormValues {
const out: FormValues = {};
for (const inp of schema) {
if (inp.defaultValue !== undefined && inp.defaultValue !== '') out[inp.nodeId] = inp.defaultValue;
}
return out;
const out: FormValues = {};
for (const inp of schema) {
if (inp.defaultValue !== undefined && inp.defaultValue !== "")
out[inp.nodeId] = inp.defaultValue;
}
return out;
}
export function isChanged(inp: ComfyDynamicInput, values: FormValues): boolean {
const v = values[inp.nodeId];
const orig = inp.defaultValue;
if (v === undefined || v === '') return orig !== undefined && orig !== '';
if (orig === undefined || orig === '') return true;
return String(v) !== String(orig);
const v = values[inp.nodeId];
const orig = inp.defaultValue;
if (v === undefined || v === "") return orig !== undefined && orig !== "";
if (orig === undefined || orig === "") return true;
return String(v) !== String(orig);
}
export function changedParams(ds: DeskSchema, values: FormValues): ComfyDynamicInput[] {
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
export function changedParams(
ds: DeskSchema,
values: FormValues,
): ComfyDynamicInput[] {
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
}
export function outputSize(ds: DeskSchema, values: FormValues): { width: number; height: number } | null {
if (!ds.ratio?.resolution) return null;
const mp = Number(values[ds.megapixels?.nodeId ?? ''] ?? ds.megapixels?.defaultValue);
return resolutionSelectorSize(
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
mp,
ds.ratio.resolution.multiple,
);
export function outputSize(
ds: DeskSchema,
values: FormValues,
): { width: number; height: number } | null {
if (!ds.ratio?.resolution) return null;
const mp = Number(
values[ds.megapixels?.nodeId ?? ""] ?? ds.megapixels?.defaultValue,
);
return resolutionSelectorSize(
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ""),
mp,
ds.ratio.resolution.multiple,
);
}
export function batchCount(ds: DeskSchema, values: FormValues): number {
if (!ds.batch) return 1;
const n = Number(values[ds.batch.nodeId]);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
if (!ds.batch) return 1;
const n = Number(values[ds.batch.nodeId]);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
}
/** H3 Ref2VA official limits (soft hint, keyed off the node class only). */
export function h3Warnings(ds: DeskSchema, values: FormValues): string[] {
if (!ds.hasH3Hint) return [];
const warns: string[] = [];
const counts = { image: 0, video: 0, audio: 0 };
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
const total = counts.image + counts.video + counts.audio;
if (total > 12) warns.push(t('wfMedia.h3TotalOver', { n: total }));
if (counts.audio > 0 && counts.image === 0 && counts.video === 0) warns.push(t('wfMedia.h3AudioAlone'));
return warns;
if (!ds.hasH3Hint) return [];
const warns: string[] = [];
const counts = { image: 0, video: 0, audio: 0 };
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
const total = counts.image + counts.video + counts.audio;
if (total > 12) warns.push(t("wfMedia.h3TotalOver", { n: total }));
if (counts.audio > 0 && counts.image === 0 && counts.video === 0)
warns.push(t("wfMedia.h3AudioAlone"));
return warns;
}
/** Same rules the backend enforces before queueing (comfyui/validate.py). */
export function valueError(inp: ComfyDynamicInput, raw: string | number): string | null {
const label = inp.label;
if (inp.kind === 'select') {
return inp.options?.length && !inp.options.includes(String(raw)) ? t('studio.errOption', { label }) : null;
}
if (inp.kind !== 'number') return null;
const n = Number(raw);
if (!Number.isFinite(n)) return t('studio.errNumber', { label });
if (inp.integer && !Number.isInteger(n)) return t('studio.errInteger', { label });
if (inp.min != null && n < inp.min) return t('studio.errMin', { label, v: inp.min });
if (inp.max != null && n > inp.max) return t('studio.errMax', { label, v: inp.max });
return null;
export function valueError(
inp: ComfyDynamicInput,
raw: string | number,
): string | null {
const label = inp.label;
if (inp.kind === "select") {
return inp.options?.length && !inp.options.includes(String(raw))
? t("studio.errOption", { label })
: null;
}
if (inp.kind !== "number") return null;
const n = Number(raw);
if (!Number.isFinite(n)) return t("studio.errNumber", { label });
if (inp.integer && !Number.isInteger(n))
return t("studio.errInteger", { label });
if (inp.min != null && n < inp.min)
return t("studio.errMin", { label, v: inp.min });
if (inp.max != null && n > inp.max)
return t("studio.errMax", { label, v: inp.max });
return null;
}
/** Field errors keyed by nodeId (spec §6.2 wording); '' keys never block. */
export function validateForm(
schema: ComfyDynamicInput[],
ds: DeskSchema,
values: FormValues,
schema: ComfyDynamicInput[],
ds: DeskSchema,
values: FormValues,
): Record<string, string> {
const errs: Record<string, string> = {};
if (ds.prompt?.required) {
const v = String(values[ds.prompt.nodeId] ?? '');
if (!v.trim()) errs[ds.prompt.nodeId] = t('studio.errPrompt');
else if (v.length > 2000) errs[ds.prompt.nodeId] = t('studio.errPromptLong');
}
const first = ds.slots.find((s) => s.firstFrame);
const last = ds.slots.find((s) => s.lastFrame);
if (first && last) {
const a = String(values[first.inp.nodeId] ?? '');
const b = String(values[last.inp.nodeId] ?? '');
// An optional last frame (H3 FL2VA) may stay empty; it never goes without a first frame.
const unpaired = last.inp.required ? (!a || !b) && (a || b) : !a && !!b;
if (unpaired) {
errs.firstLast = t('studio.errFirstLast');
if (!a) errs[first.inp.nodeId] = ' ';
if (!b) errs[last.inp.nodeId] = ' ';
}
}
for (const s of ds.slots) {
if (!s.inp.required) continue;
if (!String(values[s.inp.nodeId] ?? '').trim()) {
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
}
}
for (const g of ds.lists) {
const n = listPaths(values, g).length;
const max = g.max ?? 9;
if (n < (g.min ?? 1)) {
errs[g.nodeId] =
g.kind === 'image_list'
? t('studio.errNeedRef')
: t('studio.errNeedRefMedia', { n: g.min ?? 1, label: g.label });
} else if (n > max) {
errs[g.nodeId] = t('studio.errRefMax', { max, n, k: n - max });
}
}
for (const inp of [...ds.common, ...ds.advanced]) {
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
}
}
for (const inp of schema) {
const raw = values[inp.nodeId];
if (raw === undefined || Array.isArray(raw) || String(raw).trim() === '' || errs[inp.nodeId]) continue;
const msg = valueError(inp, raw);
if (msg) errs[inp.nodeId] = msg;
}
return errs;
const errs: Record<string, string> = {};
if (ds.prompt?.required) {
const v = String(values[ds.prompt.nodeId] ?? "");
if (!v.trim()) errs[ds.prompt.nodeId] = t("studio.errPrompt");
else if (v.length > 2000)
errs[ds.prompt.nodeId] = t("studio.errPromptLong");
}
const first = ds.slots.find((s) => s.firstFrame);
const last = ds.slots.find((s) => s.lastFrame);
if (first && last) {
const a = String(values[first.inp.nodeId] ?? "");
const b = String(values[last.inp.nodeId] ?? "");
// An optional last frame (H3 FL2VA) may stay empty; it never goes without a first frame.
const unpaired = last.inp.required ? (!a || !b) && (a || b) : !a && !!b;
if (unpaired) {
errs.firstLast = t("studio.errFirstLast");
if (!a) errs[first.inp.nodeId] = " ";
if (!b) errs[last.inp.nodeId] = " ";
}
}
for (const s of ds.slots) {
if (!s.inp.required) continue;
if (!String(values[s.inp.nodeId] ?? "").trim()) {
errs[s.inp.nodeId] =
s.missing || t("studio.errUpload", { label: s.label });
}
}
for (const g of ds.lists) {
const n = listPaths(values, g).length;
const max = g.max ?? 9;
if (n < (g.min ?? 1)) {
errs[g.nodeId] =
g.kind === "image_list"
? t("studio.errNeedRef")
: t("studio.errNeedRefMedia", { n: g.min ?? 1, label: g.label });
} else if (n > max) {
errs[g.nodeId] = t("studio.errRefMax", { max, n, k: n - max });
}
}
for (const inp of [...ds.common, ...ds.advanced]) {
if (inp.required && !String(values[inp.nodeId] ?? "").trim()) {
errs[inp.nodeId] = t("studio.errFill", { label: inp.label });
}
}
for (const inp of schema) {
const raw = values[inp.nodeId];
if (
raw === undefined ||
Array.isArray(raw) ||
String(raw).trim() === "" ||
errs[inp.nodeId]
)
continue;
const msg = valueError(inp, raw);
if (msg) errs[inp.nodeId] = msg;
}
return errs;
}
/** Media a slot still waits for — the 「还缺:…」 hint next to 生成. */
export function missingMedia(ds: DeskSchema, values: FormValues): Array<{ nodeId: string; label: string }> {
const out: Array<{ nodeId: string; label: string }> = [];
for (const s of ds.slots) {
if (s.inp.required && !String(values[s.inp.nodeId] ?? '').trim()) out.push({ nodeId: s.inp.nodeId, label: s.label });
}
for (const g of ds.lists) {
if (listPaths(values, g).length < (g.min ?? 1)) out.push({ nodeId: g.nodeId, label: g.label });
}
return out;
export function missingMedia(
ds: DeskSchema,
values: FormValues,
): Array<{ nodeId: string; label: string }> {
const out: Array<{ nodeId: string; label: string }> = [];
for (const s of ds.slots) {
if (s.inp.required && !String(values[s.inp.nodeId] ?? "").trim())
out.push({ nodeId: s.inp.nodeId, label: s.label });
}
for (const g of ds.lists) {
if (listPaths(values, g).length < (g.min ?? 1))
out.push({ nodeId: g.nodeId, label: g.label });
}
return out;
}
export function usageLabel(u: WorkflowUsage): string {
return t(`usage.${u}`);
return t(`usage.${u}`);
}
/** Mode badge: the stored usage when we have one, else the wiring shape. */
export function modeLabel(wf: Workflow | null, ds: DeskSchema, kind: DeskKind): string {
if (wf?.usage) return usageLabel(wf.usage);
if (ds.lists.some((g) => g.kind === 'image_list')) return t('studio.modeRefToVideo');
if (ds.slots.some((s) => s.firstFrame)) return t('studio.modeImageToVideo');
if (kind === 'image' && ds.slots.some((s) => s.mediaKind === 'image' && !s.mask))
return t('studio.modeImageEdit');
if (ds.slots.length) return ds.slots.map((s) => s.label).join(' · ');
return kind === 'image' ? t('studio.modeTextToImage') : t('studio.modeTextToVideo');
export function modeLabel(
wf: Workflow | null,
ds: DeskSchema,
kind: DeskKind,
): string {
if (wf?.usage) return usageLabel(wf.usage);
if (ds.lists.some((g) => g.kind === "image_list"))
return t("studio.modeRefToVideo");
if (ds.slots.some((s) => s.firstFrame)) return t("studio.modeImageToVideo");
if (
kind === "image" &&
ds.slots.some((s) => s.mediaKind === "image" && !s.mask)
)
return t("studio.modeImageEdit");
if (ds.slots.length) return ds.slots.map((s) => s.label).join(" · ");
return kind === "image"
? t("studio.modeTextToImage")
: t("studio.modeTextToVideo");
}
/** Picker row summary of a workflow's real media inputs, e.g. 参考图 1–9 张. */
export function slotSummary(wf: Workflow): string {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
const parts = [
...ds.slots.map((s) => t('desk.slotOne', { label: s.label })),
...ds.lists.map((g) => t('desk.slotRange', { label: g.label, min: g.min ?? 1, max: g.max ?? 9 })),
];
return parts.length ? parts.join(' · ') : t('desk.promptOnly');
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
const parts = [
...ds.slots.map((s) => t("desk.slotOne", { label: s.label })),
...ds.lists.map((g) =>
t("desk.slotRange", { label: g.label, min: g.min ?? 1, max: g.max ?? 9 }),
),
];
return parts.length ? parts.join(" · ") : t("desk.promptOnly");
}
/** Short chip text for one common parameter value. */
export function chipValue(inp: ComfyDynamicInput, values: FormValues, ds: DeskSchema): string {
const raw = values[inp.nodeId] ?? inp.defaultValue ?? '';
const v = Array.isArray(raw) ? raw.join(',') : String(raw);
if (inp === ds.ratio) {
const size = outputSize(ds, values);
return size ? `${ratioShort(v)} · ${size.width}×${size.height}` : ratioShort(v);
}
if (inp === ds.duration) return v ? t('desk.chipSeconds', { v }) : t('studio.durationLabel');
if (inp === ds.seed) return t('desk.chipSeed', { v: v.length > 8 ? `${v.slice(0, 8)}…` : v || '—' });
if (inp === ds.resolution) return t('desk.chipResolution', { v: v || '—' });
return v || inp.label;
export function chipValue(
inp: ComfyDynamicInput,
values: FormValues,
ds: DeskSchema,
): string {
const raw = values[inp.nodeId] ?? inp.defaultValue ?? "";
const v = Array.isArray(raw) ? raw.join(",") : String(raw);
if (inp === ds.ratio) {
const size = outputSize(ds, values);
return size
? `${ratioShort(v)} · ${size.width}×${size.height}`
: ratioShort(v);
}
if (inp === ds.duration)
return v ? t("desk.chipSeconds", { v }) : t("studio.durationLabel");
if (inp === ds.seed)
return t("desk.chipSeed", {
v: v.length > 8 ? `${v.slice(0, 8)}…` : v || "—",
});
if (inp === ds.resolution) return t("desk.chipResolution", { v: v || "—" });
return v || inp.label;
}
@@ -1,120 +1,120 @@
import { SvelteSet } from 'svelte/reactivity';
import type { QueryClient } from '@tanstack/svelte-query';
import { apiErrorMessage, playgroundApi } from '$lib/api';
import type { Job } from '$lib/comfy/types';
import { showToast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import type { QueryClient } from "@tanstack/svelte-query";
import { apiErrorMessage, playgroundApi } from "$lib/api";
import type { Job } from "$lib/comfy/types";
import { showToast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
/**
* 撤销删除 (spec-v2 §四.4 删除两级制 · HEL-654 D 批): deleting a single
* take/task never opens a dialog. The row leaves the view right away, a
* 5-second Toast offers 撤销, and the backend DELETE is only sent when
* that window closes without an undo — the backend removes result files,
* so an undo after the call would be impossible. Leaving the page (SPA
* route change or hard unload) flushes pending deletes instead of
* dropping them.
*/
* 撤销删除 (spec-v2 §四.4 删除两级制 · HEL-654 D 批): deleting a single
* take/task never opens a dialog. The row leaves the view right away, a
* 5-second Toast offers 撤销, and the backend DELETE is only sent when
* that window closes without an undo — the backend removes result files,
* so an undo after the call would be impossible. Leaving the page (SPA
* route change or hard unload) flushes pending deletes instead of
* dropping them.
*/
export interface TakeDeleteHandlers {
/** Localized toast message for this take. */
message: (job: Job) => string;
/** Undo: the row comes back — reselect / replay its entrance here. */
onUndo?: (job: Job) => void;
/** Called after the delete really hit the backend. */
onCommitted?: (job: Job) => void;
/** Localized toast message for this take. */
message: (job: Job) => string;
/** Undo: the row comes back — reselect / replay its entrance here. */
onUndo?: (job: Job) => void;
/** Called after the delete really hit the backend. */
onCommitted?: (job: Job) => void;
}
export interface TakeDeleteQueue {
/** Ids whose rows should be hidden (commit pending). */
readonly pending: SvelteSet<number>;
request: (job: Job, handlers: TakeDeleteHandlers) => void;
destroy: () => void;
/** Ids whose rows should be hidden (commit pending). */
readonly pending: SvelteSet<number>;
request: (job: Job, handlers: TakeDeleteHandlers) => void;
destroy: () => void;
}
const UNDO_MS = 5000;
export function createTakeDeleteQueue(client: QueryClient): TakeDeleteQueue {
const pending = new SvelteSet<number>();
const entries = new Map<
number,
{
job: Job;
handlers: TakeDeleteHandlers;
timer: ReturnType<typeof setTimeout>;
}
>();
let destroyed = false;
const pending = new SvelteSet<number>();
const entries = new SvelteMap<
number,
{
job: Job;
handlers: TakeDeleteHandlers;
timer: ReturnType<typeof setTimeout>;
}
>();
let destroyed = false;
async function invalidate() {
await Promise.allSettled([
client.invalidateQueries({ queryKey: ['playground-jobs'] }),
client.invalidateQueries({ queryKey: ['jobs'] }),
]);
}
async function invalidate() {
await Promise.allSettled([
client.invalidateQueries({ queryKey: ["playground-jobs"] }),
client.invalidateQueries({ queryKey: ["jobs"] }),
]);
}
async function commit(id: number) {
const entry = entries.get(id);
if (!entry) return;
entries.delete(id);
try {
await playgroundApi.deleteJob(id);
pending.delete(id);
entry.handlers.onCommitted?.(entry.job);
} catch (err) {
// Delete failed: the take stays — bring the row back honestly.
pending.delete(id);
showToast(
t('studio.deleteFailedToast', { detail: apiErrorMessage(err) }),
'error',
);
}
await invalidate();
}
async function commit(id: number) {
const entry = entries.get(id);
if (!entry) return;
entries.delete(id);
try {
await playgroundApi.deleteJob(id);
pending.delete(id);
entry.handlers.onCommitted?.(entry.job);
} catch (err) {
// Delete failed: the take stays — bring the row back honestly.
pending.delete(id);
showToast(
t("studio.deleteFailedToast", { detail: apiErrorMessage(err) }),
"error",
);
}
await invalidate();
}
/** Hard unload (tab close / external nav): fire-and-forget with keepalive. */
function onPageHide() {
for (const [id] of entries) {
void fetch(`/api/playground/jobs/${id}`, {
method: 'DELETE',
keepalive: true,
});
}
}
/** Hard unload (tab close / external nav): fire-and-forget with keepalive. */
function onPageHide() {
for (const [id] of entries) {
void fetch(`/api/playground/jobs/${id}`, {
method: "DELETE",
keepalive: true,
});
}
}
function destroy() {
if (destroyed) return;
destroyed = true;
if (inBrowser) window.removeEventListener('pagehide', onPageHide);
// SPA route change: the JS context survives, normal commits are fine.
for (const [id] of entries) {
const entry = entries.get(id)!;
clearTimeout(entry.timer);
void commit(id);
}
}
function destroy() {
if (destroyed) return;
destroyed = true;
if (inBrowser) window.removeEventListener("pagehide", onPageHide);
// SPA route change: the JS context survives, normal commits are fine.
for (const [id] of entries) {
const entry = entries.get(id)!;
clearTimeout(entry.timer);
void commit(id);
}
}
const inBrowser = typeof window !== 'undefined';
if (inBrowser) window.addEventListener('pagehide', onPageHide);
const inBrowser = typeof window !== "undefined";
if (inBrowser) window.addEventListener("pagehide", onPageHide);
return {
pending,
request(job, handlers) {
if (destroyed || pending.has(job.id)) return;
pending.add(job.id);
const timer = setTimeout(() => void commit(job.id), UNDO_MS);
entries.set(job.id, { job, handlers, timer });
showToast(handlers.message(job), 'info', UNDO_MS, {
label: t('toast.undo'),
run: () => {
const e = entries.get(job.id);
if (!e) return;
clearTimeout(e.timer);
entries.delete(job.id);
pending.delete(job.id);
handlers.onUndo?.(job);
},
});
},
destroy,
};
return {
pending,
request(job, handlers) {
if (destroyed || pending.has(job.id)) return;
pending.add(job.id);
const timer = setTimeout(() => void commit(job.id), UNDO_MS);
entries.set(job.id, { job, handlers, timer });
showToast(handlers.message(job), "info", UNDO_MS, {
label: t("toast.undo"),
run: () => {
const e = entries.get(job.id);
if (!e) return;
clearTimeout(e.timer);
entries.delete(job.id);
pending.delete(job.id);
handlers.onUndo?.(job);
},
});
},
destroy,
};
}
+147 -110
View File
@@ -1,157 +1,194 @@
import type { Job, Workflow } from '$lib/comfy/types';
import { parseDbTs } from '$lib/dbtime';
import { jobStatusInfo } from '$lib/jobStatus';
import { resolutionSelectorSize } from '$lib/comfy/resolution';
import { t } from '$lib/i18n.svelte';
import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from './schema';
import type { Job, Workflow } from "$lib/comfy/types";
import { parseDbTs } from "$lib/dbtime";
import { jobStatusInfo } from "$lib/jobStatus";
import { resolutionSelectorSize } from "$lib/comfy/resolution";
import { t } from "$lib/i18n.svelte";
import {
classifySchema,
inputRole,
isFirstFrame,
isLastFrame,
isMedia,
} from "./schema";
/** Read-only views of a finished / running take (one playground job). */
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
export type FeedFilter = "all" | "image" | "video" | "failed";
/** The workbench source a take was generated with, if any (HEL-684). */
export interface TakeWorkbenchCtx {
projectId: number;
stage: string;
sourceKind: 'asset' | 'shot';
sourceId: string;
label?: string;
projectId: number;
stage: string;
sourceKind: "asset" | "shot";
sourceId: string;
label?: string;
}
export function workbenchCtxOf(job: Job): TakeWorkbenchCtx | null {
const stamp = job.payload?.workbench;
if (!stamp || typeof stamp !== 'object') return null;
const s = stamp as Record<string, unknown>;
const projectId = s.project_id;
const stage = typeof s.stage === 'string' ? s.stage : '';
const sourceKind = s.source_kind === 'asset' || s.source_kind === 'shot' ? s.source_kind : null;
const sourceId = typeof s.source_id === 'string' ? s.source_id : '';
if (typeof projectId !== 'number' || !stage || !sourceKind || !sourceId) return null;
const label = typeof s.label === 'string' && s.label.trim() ? s.label : undefined;
return {
projectId,
stage,
sourceKind,
sourceId,
label,
};
const stamp = job.payload?.workbench;
if (!stamp || typeof stamp !== "object") return null;
const s = stamp as Record<string, unknown>;
const projectId = s.project_id;
const stage = typeof s.stage === "string" ? s.stage : "";
const sourceKind =
s.source_kind === "asset" || s.source_kind === "shot"
? s.source_kind
: null;
const sourceId = typeof s.source_id === "string" ? s.source_id : "";
if (typeof projectId !== "number" || !stage || !sourceKind || !sourceId)
return null;
const label =
typeof s.label === "string" && s.label.trim() ? s.label : undefined;
return {
projectId,
stage,
sourceKind,
sourceId,
label,
};
}
export function jobValues(job: Job): Record<string, unknown> {
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
}
export function jobValueByRole(job: Job, wf: Workflow | undefined, ...roles: string[]): string | null {
if (!wf) return null;
const vals = jobValues(job);
for (const inp of wf.input_schema ?? []) {
const r = inputRole(inp);
if (r && roles.includes(r)) {
const v = vals[inp.nodeId];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v);
}
}
return null;
export function jobValueByRole(
job: Job,
wf: Workflow | undefined,
...roles: string[]
): string | null {
if (!wf) return null;
const vals = jobValues(job);
for (const inp of wf.input_schema ?? []) {
const r = inputRole(inp);
if (r && roles.includes(r)) {
const v = vals[inp.nodeId];
if (v !== undefined && v !== null && String(v).trim() !== "")
return String(v);
}
}
return null;
}
export function promptOf(job: Job, wf: Workflow | undefined): string {
return jobValueByRole(job, wf, 'prompt') ?? '';
return jobValueByRole(job, wf, "prompt") ?? "";
}
export interface TakeInput {
label: string;
path: string;
kind: 'image' | 'video' | 'audio';
label: string;
path: string;
kind: "image" | "video" | "audio";
}
/** Media the take was generated from, in workflow order. */
export function inputsOf(job: Job, wf: Workflow | undefined): TakeInput[] {
if (!wf) return [];
const kind = wf.kind;
const vals = jobValues(job);
const out: TakeInput[] = [];
let refIdx = 0;
for (const inp of wf.input_schema ?? []) {
if (!isMedia(inp)) continue;
const v = vals[inp.nodeId];
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
const mk = inp.kind.startsWith('video') ? 'video' : inp.kind.startsWith('audio') ? 'audio' : 'image';
list.forEach((p, i) => {
if (typeof p !== 'string' || !p) return;
let label = inp.label;
if (inp.kind === 'image_list') label = t('studio.slotRefN', { n: i + 1 });
else if (isFirstFrame(inp, kind)) label = t('studio.slotFirst');
else if (isLastFrame(inp, kind)) label = t('studio.slotLast');
else if (inp.kind === 'image' && inputRole(inp) !== 'mask') label = t('studio.slotRefN', { n: ++refIdx });
out.push({ label, path: p, kind: mk });
});
}
return out;
if (!wf) return [];
const kind = wf.kind;
const vals = jobValues(job);
const out: TakeInput[] = [];
let refIdx = 0;
for (const inp of wf.input_schema ?? []) {
if (!isMedia(inp)) continue;
const v = vals[inp.nodeId];
const list = Array.isArray(v) ? v : typeof v === "string" && v ? [v] : [];
const mk = inp.kind.startsWith("video")
? "video"
: inp.kind.startsWith("audio")
? "audio"
: "image";
list.forEach((p, i) => {
if (typeof p !== "string" || !p) return;
let label = inp.label;
if (inp.kind === "image_list") label = t("studio.slotRefN", { n: i + 1 });
else if (isFirstFrame(inp, kind)) label = t("studio.slotFirst");
else if (isLastFrame(inp, kind)) label = t("studio.slotLast");
else if (inp.kind === "image" && inputRole(inp) !== "mask")
label = t("studio.slotRefN", { n: ++refIdx });
out.push({ label, path: p, kind: mk });
});
}
return out;
}
/** 888×1184 · 种子 42 · 5 秒 — values that differ per take. */
export function specOf(job: Job, wf: Workflow | undefined): Array<{ label: string; value: string }> {
const out: Array<{ label: string; value: string }> = [];
if (!wf) return out;
const w = jobValueByRole(job, wf, 'width');
const h = jobValueByRole(job, wf, 'height');
if (w && h) out.push({ label: t('desk.specSize'), value: `${w}×${h}` });
else {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
if (ds.ratio?.resolution) {
const vals = jobValues(job);
const size = resolutionSelectorSize(
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
Number(vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue),
ds.ratio.resolution.multiple,
);
if (size) out.push({ label: t('desk.specSize'), value: `${size.width}×${size.height}` });
}
}
const seed = jobValueByRole(job, wf, 'seed');
if (seed) out.push({ label: t('studio.seedLabel'), value: seed });
const dur = jobValueByRole(job, wf, 'duration');
if (dur) out.push({ label: t('studio.durationLabel'), value: t('desk.chipSeconds', { v: dur }) });
return out;
export function specOf(
job: Job,
wf: Workflow | undefined,
): Array<{ label: string; value: string }> {
const out: Array<{ label: string; value: string }> = [];
if (!wf) return out;
const w = jobValueByRole(job, wf, "width");
const h = jobValueByRole(job, wf, "height");
if (w && h) out.push({ label: t("desk.specSize"), value: `${w}×${h}` });
else {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
if (ds.ratio?.resolution) {
const vals = jobValues(job);
const size = resolutionSelectorSize(
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ""),
Number(
vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue,
),
ds.ratio.resolution.multiple,
);
if (size)
out.push({
label: t("desk.specSize"),
value: `${size.width}×${size.height}`,
});
}
}
const seed = jobValueByRole(job, wf, "seed");
if (seed) out.push({ label: t("studio.seedLabel"), value: seed });
const dur = jobValueByRole(job, wf, "duration");
if (dur)
out.push({
label: t("studio.durationLabel"),
value: t("desk.chipSeconds", { v: dur }),
});
return out;
}
export function isVideoOutput(job: Job, path: string): boolean {
return job.kind === 'video' && !/\.(png|jpe?g|webp)$/i.test(path);
return job.kind === "video" && !/\.(png|jpe?g|webp)$/i.test(path);
}
export function elapsedSec(job: Job, nowMs: number): number | null {
const from = job.started_at ? parseDbTs(job.started_at) : parseDbTs(job.created_at);
if (!Number.isFinite(from)) return null;
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
return Math.max(0, (to - from) / 1000);
const from = job.started_at
? parseDbTs(job.started_at)
: parseDbTs(job.created_at);
if (!Number.isFinite(from)) return null;
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
return Math.max(0, (to - from) / 1000);
}
export function fmtDur(sec: number | null): string {
if (sec == null) return '—';
if (sec < 60) return t('desk.durSec', { s: Math.floor(sec) });
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return t('desk.durMin', { m, s: String(s).padStart(2, '0') });
if (sec == null) return "—";
if (sec < 60) return t("desk.durSec", { s: Math.floor(sec) });
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return t("desk.durMin", { m, s: String(s).padStart(2, "0") });
}
export function fmtClock(ms: number): string {
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
/** 今天 / 昨天 / 9月28日 heading for the feed. */
export function dayLabel(ms: number, nowMs: number): string {
const d = new Date(ms);
const today = new Date(nowMs);
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
if (diff === 0) return t('desk.today');
if (diff === 1) return t('desk.yesterday');
return t('desk.dayDate', { m: d.getMonth() + 1, d: d.getDate() });
const d = new Date(ms);
const today = new Date(nowMs);
const startOf = (x: Date) =>
new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
if (diff === 0) return t("desk.today");
if (diff === 1) return t("desk.yesterday");
return t("desk.dayDate", { m: d.getMonth() + 1, d: d.getDate() });
}
export function matchesFilter(job: Job, filter: FeedFilter): boolean {
if (filter === 'all') return true;
if (filter === 'failed') return jobStatusInfo(job).tone === 'fail';
return job.kind === filter;
if (filter === "all") return true;
if (filter === "failed") return jobStatusInfo(job).tone === "fail";
return job.kind === filter;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import { assetUrl, projects, type Project } from '$lib/api';
@@ -186,7 +187,7 @@
type="button"
class="card-main"
aria-label={t('projectCard.open', { title: project.title })}
onclick={() => goto(`/workbench/${project.id}`)}
onclick={() => goto(resolve(`/workbench/${project.id}`))}
>
<span class="cover">
{#if coverUrl && !coverFailed}
@@ -8,7 +8,7 @@
];
</script>
{#each pending.filter((item) => kind === "all" || item.kind === kind) as item}<article
{#each pending.filter((item) => kind === "all" || item.kind === kind) as item (item.name)}<article
class="mg-card pending"
>
<div class="mg-row">
@@ -6,6 +6,7 @@
useQueryClient,
} from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { fade } from "svelte/transition";
import Modal from "$lib/components/ui/Modal.svelte";
@@ -385,7 +386,7 @@
// Probe every connection once per visit so the cards show real state
// instead of 「未测试」; results younger than a minute are reused.
const autoProbed = new Set<string>();
const autoProbed = new SvelteSet<string>();
$effect(() => {
const list = endpoints;
untrack(() => {
@@ -5,6 +5,7 @@
useQueryClient,
} from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { fade } from "svelte/transition";
import Modal from "$lib/components/ui/Modal.svelte";
@@ -515,7 +516,7 @@
// Test each model once per visit (1-token probe) so the cards show real
// state instead of 「未测试」; results younger than 10 minutes are reused.
const autoProbed = new Set<string>();
const autoProbed = new SvelteSet<string>();
$effect(() => {
const list = $settingsQuery.data?.llm_profiles ?? [];
untrack(() => {
@@ -28,6 +28,10 @@
>
<b>{title}</b>{@render tools?.()}<span class="mg-grow"
></span>{@render actions?.()}
<!-- The approved header reserves this inline width independently of demo copy. -->
<span class="mg-header-reserve" aria-hidden="true"
><span></span><span>&emsp;&emsp;&emsp;&emsp;</span></span
>
</header>
{/snippet}
<section
@@ -138,7 +138,7 @@
overflow: auto;
}
.mg-table table {
table-layout: fixed;
table-layout: var(--manage-table-layout, fixed);
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
@@ -148,6 +148,7 @@
top: 0;
background: var(--bg-0);
z-index: 1;
box-sizing: var(--manage-cell-box, content-box);
text-align: left;
font-weight: 500;
color: var(--tx-3);
@@ -156,6 +157,7 @@
border-bottom: 1px solid var(--line);
}
.mg-table td {
box-sizing: var(--manage-cell-box, content-box);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -752,3 +754,101 @@ textarea.f-input {
.mg-pin[aria-pressed="true"] svg {
fill: currentColor;
}
/* Confirmed full-width task table. Shared table/controls remain the single source. */
.task-table {
--manage-table-layout: var(--task-table-layout);
--manage-cell-box: border-box;
}
.task-thumbnail-column {
width: var(--task-thumbnail-column);
}
.task-progress-column {
width: var(--task-progress-column);
}
.task-tabs {
width: var(--task-tabs-width);
margin-left: var(--task-tabs-offset);
}
.task-search {
width: var(--task-search-width);
}
.task-thumbnail {
display: grid;
place-items: center;
width: var(--task-thumbnail-width);
height: var(--task-thumbnail-height);
border-radius: var(--task-thumbnail-radius);
border: 0;
padding: 0;
overflow: hidden;
color: var(--tx-3);
background: var(--bg-2);
cursor: pointer;
}
.task-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.task-name {
font-weight: 500;
}
.task-progress {
height: var(--task-progress-height);
margin-top: var(--task-progress-gap);
border-radius: var(--task-progress-radius);
background: rgb(var(--ink) / 0.1);
overflow: hidden;
}
.task-progress i {
display: block;
height: 100%;
background: var(--task-progress-fill);
box-shadow: var(--task-progress-shadow);
transition: width var(--task-progress-duration) linear;
}
.task-progress-unknown {
color: var(--tx-3);
margin-left: var(--task-progress-gap);
}
.task-table .mg-tag.run,
.task-dialog .mg-tag.run {
color: var(--task-running-color);
border-color: var(--task-running-border);
background: var(--task-running-background);
}
.task-dialog {
height: auto;
overflow: visible;
gap: 12px;
}
.task-output {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-output img,
.task-output video {
width: 100%;
max-height: var(--task-preview-height);
object-fit: contain;
background: var(--media-bg);
}
.mg-header-reserve {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
padding: 0 var(--manage-header-reserve-padding);
border: 1px solid transparent;
font: var(--manage-header-reserve-font);
letter-spacing: inherit;
}
.mg-header-reserve > :first-child {
width: var(--manage-header-reserve-dot);
}
.mg-header-reserve > :last-child {
white-space: pre;
}
@@ -166,7 +166,6 @@
>
<div class="thumb">
{#if isVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={src} muted playsinline preload="metadata" onerror={hideBroken}></video>
{:else if (opt.kind ?? kind) === 'audio'}
<Icon name="music" size={28} />
@@ -78,6 +78,7 @@
// The same path can arrive twice (e.g. a scene clip that was also
// uploaded to the playground). Duplicate keys crash the keyed each
// in the picker grid, so dedupe by path first.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Local path deduplication scratch set for one filter pass; only the filtered asset array escapes.
const seen = new Set<string>();
return assetOptions.filter((o) => {
const match =
@@ -144,7 +145,6 @@
{:else if value && thumbSrc && isImageKind}
<img class="tile-thumb" src={thumbSrc} alt={displayLabel} onerror={hideBrokenThumb} />
{:else if value && isVideoKind && thumbSrc}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="tile-thumb" src={thumbSrc} muted playsinline></video>
{:else if value}
<div class="tile-file">
@@ -63,6 +63,7 @@
if (!open || !panelEl) return;
document.body.appendChild(panelEl);
return () => {
// eslint-disable-next-line svelte/no-dom-manipulating -- Template creates panel contents; effect owns body portal placement and teardown. Remove the moved root on close/unmount; listener cleanup stays separate.
panelEl?.remove();
};
});
@@ -85,6 +85,7 @@
if (!open || !menuEl) return;
document.body.appendChild(menuEl);
return () => {
// eslint-disable-next-line svelte/no-dom-manipulating -- Template creates menu contents; effect owns body portal placement and teardown. Remove the moved root on close/unmount; selection and listener cleanup stay intact.
menuEl?.remove();
};
});
@@ -1,6 +1,6 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { onMount } from "svelte";
import { spotlight } from "$lib/actions/spotlight";
import { createQuery } from "@tanstack/svelte-query";
import { toStore } from "svelte/store";
import { page } from "$app/state";
@@ -62,10 +62,6 @@
(j) => j.kind === "image" || j.kind === "video",
),
);
const activeJobs = $derived(
genJobs.filter((j) => j.status === "pending" || j.status === "running")
.length,
);
// The ACTIVE ComfyUI connection (HEL-611). Its status dot comes from a
// real server-side probe (GET /system_stats from the workbench server,
@@ -115,11 +111,6 @@
return $probeQuery.data?.ok ? "ok" : "fail";
});
const endpointName = $derived(activeEndpoint?.name?.trim() || "ComfyUI");
const latencyMs = $derived(
probeState === "ok" && $probeQuery.data?.elapsed_ms != null
? Math.round($probeQuery.data.elapsed_ms)
: null,
);
const connDetail = $derived.by(() => {
if (probeState === "unset") return t("shell.comfyUnset");
const state = t(`shell.comfy_${probeState}`, { name: endpointName });
@@ -192,48 +183,61 @@
class="refract-shell"
class:focus
class:expanded
style:--scene={`var(--sc-${scene})`}
style:--scene={scene === "gold" ? "var(--gold)" : `var(--sc-${scene})`}
>
<aside class="sidebar" aria-label="全站导航">
<a class="brand" href="/" aria-label="返回工作室"
>AIGC{#if expanded}<small>创作工作台</small>{/if}</a
<a class="brand" href={resolve("/")} aria-label="返回工作室"
><b>片</b>{#if expanded}<small>创作工作台</small>{/if}</a
>
<nav aria-label="主要导航">
{#each mainNav as item}<a
class="nav-item reference-option"
use:spotlight
href={item.href}
style:--nav-c={`var(--sc-${item.scene})`}
{#each mainNav as item (item.id)}<a
class="nav-item"
href={resolve(item.href)}
style:--nav-c={item.scene === "gold"
? "var(--gold)"
: `var(--sc-${item.scene})`}
aria-current={item.matches(page.url.pathname) ? "page" : undefined}
title={item.label}
><Icon name={item.icon} size={20} /><span>{item.label}</span></a
title={item.id === "connections"
? `${item.label} · ${connDetail}`
: item.label}
><Icon name={item.icon} size={18} /><span>{item.label}</span></a
>{/each}
</nav>
<hr />
<nav class="management" aria-label="管理导航">
{#each NAV_MANAGE as item}<a
class="nav-item reference-option"
use:spotlight
href={item.href}
style:--nav-c={`var(--sc-${item.scene})`}
{#each NAV_MANAGE as item (item.id)}<a
class="nav-item"
href={resolve(item.href)}
style:--nav-c={item.scene === "gold"
? "var(--gold)"
: `var(--sc-${item.scene})`}
aria-current={item.matches(page.url.pathname) ? "page" : undefined}
title={item.label}
title={item.id === "connections"
? `${item.label} · ${connDetail}`
: item.label}
><Icon name={item.icon} size={18} /><span>{item.label}</span></a
>{/each}
</nav>
<div class="sidebar-bottom">
<button
class="nav-item reference-option"
use:spotlight
onclick={toggleNavigation}
aria-expanded={expanded}
title={expanded ? "收起导航" : "展开导航"}
><Icon name="chevron-right" size={18} /><span
>{expanded ? "收起导航" : "展开导航"}</span
></button
<a
class="queue-dot"
href={resolve("/tasks")}
title={`${genJobs.filter((j) => j.status === "running" || j.status === "pending").length} 个任务进行中`}
aria-label="查看生成任务"
>
<Icon name="nav-queue" />
{#if genJobs.some((j) => j.status === "running") && !reduceMotion.manual && !reduceMotion.system}<span
class="queue-ring"
aria-hidden="true"
></span>{/if}
{#if genJobs.some((j) => j.status === "running" || j.status === "pending")}<i
>{genJobs.filter(
(j) => j.status === "running" || j.status === "pending",
).length}</i
>{/if}
</a>
<button
class="nav-item reference-option"
use:spotlight
class="nav-item"
onclick={() => (themeOpen = true)}
aria-haspopup="dialog"
aria-expanded={themeOpen}
@@ -242,27 +246,36 @@
></button
>
<a
class="nav-item reference-option"
use:spotlight
href="/settings"
class="nav-item"
href={resolve("/settings")}
aria-current={page.url.pathname === "/settings" ? "page" : undefined}
title="设置"><Icon name="settings" size={18} /><span>设置</span></a
title="设置"><Icon name="nav-gear" size={18} /><span>设置</span></a
>
<a
class="connection"
href="/connections"
title={connDetail}
aria-label={connDetail}
><span class="status-chip-dot {connDot}"></span>{#if expanded}<span
>{probeState === "ok"
? "本地服务已连接"
: probeState === "testing"
? "正在检测服务"
: probeState === "fail"
? "本地服务离线"
: "配置本地服务"}</span
>{/if}</a
<button
class="nav-item"
onclick={toggleNavigation}
aria-expanded={expanded}
title={expanded ? "收起导航" : "展开导航"}
><Icon name="nav-palette" size={18} /><span
>{expanded ? "收起" : "导航"}</span
></button
>
{#if expanded}<a
class="connection"
href={resolve("/connections")}
title={connDetail}
aria-label={connDetail}
><span class="status-chip-dot {connDot}"></span>{#if expanded}<span
>{probeState === "ok"
? "本地服务已连接"
: probeState === "testing"
? "正在检测服务"
: probeState === "fail"
? "本地服务离线"
: "配置本地服务"}</span
>{/if}</a
>
{/if}
</div>
</aside>
<div
@@ -280,14 +293,14 @@
{#if currentNav?.id === "connections"}
<a
class="a-btn secondary sm"
href="/connections"
href={resolve("/connections")}
aria-current={page.url.pathname === "/connections"
? "page"
: undefined}>ComfyUI 连接</a
>
<a
class="a-btn secondary sm"
href="/models"
href={resolve("/models")}
aria-current={page.url.pathname === "/models" ? "page" : undefined}
>大语言模型</a
>
@@ -333,11 +346,6 @@
</Modal>
<style>
.management {
border-top: 1px solid var(--line-2);
margin-top: 8px;
padding-top: 8px;
}
.theme-dot {
width: 18px;
height: 18px;
@@ -361,7 +369,7 @@
color: var(--tx-2);
}
.nav-item[aria-current="page"] :global(svg) {
color: var(--nav-c, var(--scene));
color: var(--nav-c, var(--gold));
}
.refract-shell {
@@ -374,28 +382,45 @@
min-width: 0;
}
.refract-shell.expanded {
grid-template-columns: 174px minmax(0, 1fr);
grid-template-columns: var(--rail-expanded-width) minmax(0, 1fr);
}
.sidebar {
background: var(--bg-1);
border-right: 1px solid var(--line);
padding: 16px 8px 9px;
--icon-stroke: var(--manage-icon-stroke);
padding: var(--rail-padding);
align-items: center;
gap: 2px;
z-index: 5;
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
scrollbar-width: thin;
overflow: visible;
}
.brand {
font-size: 19px;
font-weight: 800;
color: var(--gold);
text-align: center;
width: var(--rail-brand-size);
height: var(--rail-brand-size);
border-radius: var(--rail-brand-radius);
display: grid;
place-items: center;
margin-bottom: var(--rail-brand-margin);
position: relative;
background: var(--brand-grad);
box-shadow: var(--brand-glow);
text-decoration: none;
letter-spacing: -1px;
display: block;
margin: 2px 0 18px;
text-shadow: var(--brand-glow);
flex: none;
}
.brand b {
font: 800 15px/1 var(--font);
color: var(--brand-ink);
letter-spacing: -0.5px;
}
.sidebar hr {
width: 28px;
border: 0;
border-top: 1px solid var(--line-2);
margin: var(--rail-divider-margin);
flex: none;
}
.brand small {
display: block;
@@ -411,26 +436,24 @@
gap: 2px;
}
.nav-item {
min-height: 44px;
box-sizing: border-box;
position: relative;
width: var(--rail-nav-width);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
font-size: 10px;
line-height: 1;
padding: 7px 3px;
gap: 3px;
font: inherit;
padding: var(--rail-nav-padding);
color: var(--tx-3);
text-decoration: none;
border: 0;
background: none;
border-radius: var(--r-md);
border-radius: var(--rail-nav-radius);
cursor: pointer;
font-family: inherit;
transition:
background-color 180ms ease,
color 180ms ease,
transform 160ms var(--ease-out);
color var(--t-base),
background var(--t-base);
flex-shrink: 0;
}
.nav-item[aria-current="page"] {
@@ -444,20 +467,30 @@
.nav-item[aria-current="page"]::before {
content: "";
position: absolute;
left: -2px;
left: -6px;
top: 12px;
bottom: 12px;
width: 3px;
border-radius: 3px;
background: var(--nav-c, var(--scene));
background: var(--nav-c, var(--gold));
box-shadow: var(
--rail-active-shadow,
0 0 var(--rail-active-shadow-blur) var(--nav-c, var(--gold))
);
}
.nav-item:active {
transform: scale(var(--press-scale));
}
.nav-item > span {
font-size: var(--rail-nav-size);
line-height: 1;
white-space: nowrap;
}
.expanded .sidebar {
overflow-y: auto;
}
.expanded .nav-item {
width: var(--rail-expanded-nav-width);
flex-direction: row;
justify-content: flex-start;
gap: 12px;
@@ -465,26 +498,56 @@
padding: 10px 14px;
min-height: 43px;
}
.expanded .nav-item > span {
font-size: var(--rail-expanded-label-size);
}
.expanded .nav-item::before {
display: none;
}
.sidebar-bottom {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
gap: 6px;
margin-top: auto;
padding-top: 8px;
}
.sidebar-bottom .nav-item {
min-height: 45px;
font-size: 9px;
padding: 7px 3px;
gap: 4px;
.queue-dot {
box-sizing: border-box;
width: var(--rail-queue-size);
height: var(--rail-queue-size);
border-radius: var(--rail-brand-radius);
display: grid;
place-items: center;
position: relative;
color: var(--tx-2);
background: var(--bg-2);
border: 1px solid var(--line);
}
.expanded .sidebar-bottom .nav-item {
min-height: 39px;
padding: 9px 14px;
font-size: 12px;
.queue-ring {
position: absolute;
inset: -1px;
border-radius: var(--rail-queue-ring-radius);
border: var(--rail-queue-ring-border) solid transparent;
border-top-color: var(--sc-video);
animation: rail-spin var(--rail-queue-ring-duration) linear infinite;
}
@keyframes rail-spin {
to {
transform: rotate(360deg);
}
}
.queue-dot i {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--sc-video);
color: var(--on-scene);
font: 700 10px/16px var(--mono);
font-style: normal;
}
.connection {
display: flex;
@@ -595,48 +658,4 @@
text-overflow: ellipsis;
}
}
@media (max-height: 780px) {
.sidebar {
padding-top: 11px;
}
.brand {
margin-bottom: 8px;
}
.nav-item {
min-height: 40px;
padding: 5px 0 3px;
gap: 3px;
}
.sidebar-bottom {
gap: 0;
padding-top: 12px;
}
.sidebar-bottom .nav-item {
min-height: 37px;
}
}
@media (max-height: 740px) {
.sidebar {
padding-top: 10px;
}
.brand {
margin-bottom: 12px;
}
.sidebar nav {
gap: 4px;
}
.nav-item {
min-height: 38px;
padding: 4px 2px;
gap: 3px;
}
.sidebar-bottom {
padding-top: 8px;
}
.sidebar-bottom .nav-item {
min-height: 31px;
padding: 4px 2px;
}
}
</style>
@@ -1,4 +1,6 @@
<script lang="ts">
import type { Pathname } from "$app/types";
import { resolve } from "$app/paths";
import { goto } from "$app/navigation";
import { createQuery } from "@tanstack/svelte-query";
import { toStore } from "svelte/store";
@@ -17,7 +19,7 @@
group: "pages" | "projects";
label: string;
icon: IconName;
href: string;
href: Pathname;
}
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
@@ -69,7 +71,7 @@
function go(entry: Entry) {
open = false;
void goto(entry.href);
void goto(resolve(entry.href));
}
function move(step: number) {
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
@@ -56,7 +57,7 @@
<ShellPop bind:open label={t('shell.scopeHint')} width="300px">
<a
class="a-menu-item"
href="/"
href={resolve('/')}
aria-current={onProjectsList ? 'page' : undefined}
onclick={() => (open = false)}
>
@@ -70,7 +71,7 @@
{:else if $projectsQuery.isError}
<p class="scope-note err">{t('shell.scopeLoadFailed')}</p>
{:else if recent.length === 0}
<a class="a-menu-item" href="/" onclick={() => (open = false)}>
<a class="a-menu-item" href={resolve('/')} onclick={() => (open = false)}>
<Icon name="plus" size={15} />
<span class="a-menu-label">{t('shell.scopeEmpty')}</span>
</a>
@@ -78,7 +79,7 @@
{#each recent as project (project.id)}
<a
class="a-menu-item"
href="/workbench/{project.id}"
href={resolve(`/workbench/${project.id}`)}
aria-current={project.id === projectId ? 'true' : undefined}
onclick={() => (open = false)}
>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { workflows, type Job } from '$lib/api';
@@ -93,7 +94,7 @@
{:else}
{#each recent as job (job.id)}
{@const info = jobStatusInfo(job)}
<a class="a-menu-item tray-row" href="/tasks" title={info.detail} onclick={() => (open = false)}>
<a class="a-menu-item tray-row" href={resolve('/tasks')} title={info.detail} onclick={() => (open = false)}>
<span class="tray-copy">
<span class="a-menu-label">{workflowName(job)}</span>
<span class="tray-meta">{jobSourceLabel(job)} · <span class="mono">{timeOf(job)}</span></span>
@@ -102,7 +103,7 @@
</a>
{/each}
{/if}
<a class="a-menu-item tray-all" href="/tasks" onclick={() => (open = false)}>
<a class="a-menu-item tray-all" href={resolve('/tasks')} onclick={() => (open = false)}>
<span class="a-menu-label">{t('shell.trayAll')}</span>
</a>
</ShellPop>
@@ -103,7 +103,8 @@
return Number.isFinite(ms) ? ms : fallback;
}
function popIn(_node: Element) {
function popIn(node: Element) {
void node;
const reduced = document.documentElement.dataset.reduce === '1';
const sheet = window.matchMedia(SHEET_QUERY).matches;
if (reduced) {
@@ -123,7 +124,8 @@
};
}
function fadeScrim(_node: Element) {
function fadeScrim(node: Element) {
void node;
return { duration: cssMs('--d-pop', 180), css: (t: number) => `opacity: ${t}` };
}
</script>
@@ -38,7 +38,7 @@
role="group"
aria-label="外观主题"
>
{#each THEMES as item}
{#each THEMES as item (item.id)}
<button
class="choice"
class:selected={$theme === item.id}
@@ -48,12 +48,12 @@
>
<span class="preview" data-theme={item.id} aria-hidden="true"
><span class="rail"></span><span class="list"
><i></i><i></i><i></i></span
><i></i><i></i><i></i>{#if tiles}<i></i>{/if}</span
><span class="stage"
><i></i><b
>{#if tiles}<em></em>{/if}</b
></span
></span
>{#if tiles}<span class="preview-text"></span>{/if}</span
>
<span class="copy"
><strong
@@ -80,19 +80,30 @@
}
.choices.tiles {
--choice-check-width: 0px;
--choice-display: flex;
--choice-box-sizing: border-box;
--choice-gap: 6px;
--choice-copy-gap: 6px;
--choice-tone-align: flex-start;
--choice-tone-align: stretch;
--choice-preview-art: var(--theme-preview-art);
--choice-preview-radius: 8px;
--choice-art-border: 0;
--choice-dock-radius: 3px;
--choice-dock-width: 100%;
--choice-dock-background: var(--bg-3);
--choice-radius: 12px;
--choice-name-size: 12.5px;
--choice-tone-font: 11px var(--font-body);
--choice-name-size: var(--manage-theme-name-size);
--choice-name-line: var(--manage-theme-line);
--choice-tone-font: var(--manage-theme-tone-size)/var(--manage-theme-line)
var(--font);
--choice-selected: color-mix(in srgb, var(--gold) 60%, transparent);
--choice-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 14%, transparent);
}
.tiles .preview {
--choice-art-shadow: 0 0 0 1px var(--line-2);
--choice-dock-border: 1px solid var(--line-2);
--choice-dock-background: var(--bg-3);
color: var(--tx-1);
}
.tile-check {
display: none;
}
@@ -104,7 +115,8 @@
}
.choice {
box-sizing: var(--choice-box-sizing, content-box);
display: grid;
display: var(--choice-display, grid);
flex-direction: column;
grid-template-columns: minmax(0, 1fr) var(--choice-check-width, 18px);
gap: var(--choice-gap, 8px);
padding: 8px;
@@ -135,7 +147,8 @@
box-sizing: var(--choice-box-sizing, content-box);
grid-column: 1 / -1;
height: 64px;
border-radius: var(--r-sm);
border-radius: var(--choice-preview-radius, var(--r-sm));
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: 12px 22px 1fr;
@@ -175,6 +188,7 @@
background: var(--sc-image);
}
.stage {
position: relative;
padding: 5px 6px;
display: flex;
flex-direction: column;
@@ -184,13 +198,16 @@
flex: 1;
border-radius: 3px;
background: var(--choice-preview-art, var(--bg-3));
border: 1px solid var(--line-2);
border: var(--choice-art-border, 1px solid var(--line-2));
box-shadow: var(--choice-art-shadow, none);
}
.stage b {
box-sizing: var(--choice-box-sizing, content-box);
border: var(--choice-dock-border, 0);
height: 9px;
width: var(--choice-dock-width, 18px);
align-self: end;
border-radius: 2px;
border-radius: var(--choice-dock-radius, 2px);
background: var(--choice-dock-background, var(--grad-gold));
display: flex;
justify-content: flex-end;
@@ -201,6 +218,16 @@
border-radius: 2px;
background: var(--grad-gold);
}
.preview-text {
position: absolute;
left: 40px;
top: 6px;
width: 20px;
height: 3px;
border-radius: 2px;
background: var(--tx-1);
opacity: 0.85;
}
.copy {
display: flex;
flex-wrap: wrap;
@@ -209,6 +236,7 @@
}
.copy strong {
font-size: var(--choice-name-size, 13px);
line-height: var(--choice-name-line, inherit);
}
.copy small {
font: var(--choice-tone-font, 10px var(--mono));
+16 -15
View File
@@ -1,3 +1,4 @@
import type { Pathname } from "$app/types";
import type { IconName } from "$lib/components/ui/icons";
// IDs are retained for the existing page contracts while B1–B4 replace layouts.
@@ -18,23 +19,23 @@ export interface NavItem {
id: NavId;
label: string;
icon: IconName;
href: string;
scene: "project" | "image" | "agent" | "manage" | "asset";
href: Pathname;
scene: "project" | "image" | "agent" | "manage" | "asset" | "gold";
matches: (path: string) => boolean;
}
export const NAV_MAIN: NavItem[] = [
{
id: "projects",
label: "工作室",
icon: "home",
icon: "nav-home",
href: "/",
scene: "project",
scene: "gold",
matches: (p) => p === "/",
},
{
id: "workbench",
label: "项目",
icon: "folder",
icon: "nav-project",
href: "/workbench",
scene: "project",
matches: (p) => /^\/(workbench|project)(\/|$)/.test(p),
@@ -42,7 +43,7 @@ export const NAV_MAIN: NavItem[] = [
{
id: "image",
label: "创作台",
icon: "image",
icon: "nav-create",
href: "/create",
scene: "image",
matches: (p) =>
@@ -53,7 +54,7 @@ export const NAV_MAIN: NavItem[] = [
{
id: "agents",
label: "智能体",
icon: "sparkle",
icon: "nav-agent",
href: "/agents",
scene: "agent",
matches: (p) => p === "/agents",
@@ -61,17 +62,17 @@ export const NAV_MAIN: NavItem[] = [
{
id: "canvas",
label: "画布",
icon: "story",
icon: "nav-canvas",
href: "/canvas",
scene: "manage",
scene: "image",
matches: (p) => p.startsWith("/canvas"),
},
{
id: "library",
label: "素材库",
icon: "assets",
icon: "nav-lib",
href: "/library",
scene: "asset",
scene: "gold",
matches: (p) => p === "/library",
},
];
@@ -79,7 +80,7 @@ export const NAV_MANAGE: NavItem[] = [
{
id: "tasks",
label: "任务",
icon: "clock",
icon: "nav-queue",
href: "/tasks",
scene: "manage",
matches: (p) => p === "/tasks",
@@ -87,7 +88,7 @@ export const NAV_MANAGE: NavItem[] = [
{
id: "workflows",
label: "工作流",
icon: "playground",
icon: "nav-flow",
href: "/workflows",
scene: "manage",
matches: (p) => p === "/workflows",
@@ -95,7 +96,7 @@ export const NAV_MANAGE: NavItem[] = [
{
id: "connections",
label: "模型连接",
icon: "link",
icon: "nav-plug",
href: "/model-connections",
scene: "manage",
matches: (p) =>
@@ -106,7 +107,7 @@ export const NAV_FOOT: NavItem[] = [
{
id: "settings",
label: "设置",
icon: "settings",
icon: "nav-gear",
href: "/settings",
scene: "manage",
matches: (p) => p === "/settings",
@@ -24,6 +24,7 @@
stroke-linejoin="round"
aria-hidden="true"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- content is selected only from the bundled icons.ts SVG registry; no API/user strings enter it. HTML preserves the authored SVG child elements. -->
{@html content}
</svg>
{/if}
@@ -10,6 +10,28 @@ export const icons = {
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 4v16M17 4v16M2 9h5M2 15h5M17 9h5M17 15h5"/>',
"arrow-right": '<path d="M5 12h14M13 6l6 6-6 6"/>',
pin: '<path d="M12 17v5M5 17h14l-2-4V5a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v8z"/>',
// Approved HEL-726 rail geometry; existing tool icons retain their contracts.
"nav-home":
'<path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/>',
"nav-project":
'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M8 4v5M13 4v5"/>',
"nav-create":
'<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/><circle cx="12" cy="12" r="3"/>',
"nav-agent":
'<path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/><path d="M18.5 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z"/>',
"nav-canvas":
'<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/>',
"nav-lib":
'<rect x="3" y="5" width="18" height="15" rx="2"/><circle cx="9" cy="10" r="2"/><path d="m21 16-5-5-9 9"/>',
"nav-queue":
'<path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="16" r="3"/>',
"nav-flow":
'<rect x="3" y="3" width="6" height="6" rx="1.5"/><rect x="15" y="15" width="6" height="6" rx="1.5"/><path d="M9 6h4a2 2 0 0 1 2 2v7"/>',
"nav-plug": '<path d="M9 2v5M15 2v5M6 7h12v4a6 6 0 0 1-12 0zM12 17v5"/>',
"nav-gear":
'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
"nav-palette":
'<path d="M12 3a9 9 0 1 0 0 18c1 0 1.5-.8 1.2-1.6-.4-1 .3-2.4 1.6-2.4H17a4 4 0 0 0 4-4c0-5.5-4-10-9-10z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7.5" r="1"/>',
// Navigation
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M9 22V12h6v10"/>',
story:
@@ -1,17 +1,17 @@
// Only the uppermost dialog handles the keyboard. Nested dialogs keep the
// original body overflow until the final layer closes.
const layers: symbol[] = [];
let bodyOverflow = '';
let bodyOverflow = "";
export function enterModal(token: symbol) {
if (!layers.length) bodyOverflow = document.body.style.overflow;
layers.push(token);
document.body.style.overflow = 'hidden';
if (!layers.length) bodyOverflow = document.body.style.overflow;
layers.push(token);
document.body.style.overflow = "hidden";
}
export function topModal(token: symbol) {
return layers.at(-1) === token;
return layers.at(-1) === token;
}
export function leaveModal(token: symbol) {
const index = layers.indexOf(token);
if (index >= 0) layers.splice(index, 1);
document.body.style.overflow = layers.length ? 'hidden' : bodyOverflow;
const index = layers.indexOf(token);
if (index >= 0) layers.splice(index, 1);
document.body.style.overflow = layers.length ? "hidden" : bodyOverflow;
}
@@ -111,7 +111,7 @@ let {
</div>
{#if previewUrl}
<div class="foot">
<a class="dl" href={previewUrl} download>
<a class="dl" href={previewUrl} download rel="external">
<Icon name="download" size={14} /> {t('clipMonitor.downloadClip')}
</a>
</div>
@@ -98,7 +98,6 @@
>
<div class="thumb">
{#if opt.path}
<!-- svelte-ignore a11y_media_has_caption -->
<video
src={assetUrl(opt.path)}
muted
@@ -11,7 +11,7 @@
import Button from '$lib/components/ui/Button.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import '$lib/components/SafeMedia.svelte';
import { assetUrl } from '$lib/api';
import { t } from '$lib/i18n.svelte';
@@ -100,7 +100,6 @@ title={t('filmstrip.clipTitle', {
{#if thumb?.kind === 'image'}
<img class="thumb-media" src={thumb.src} alt="" loading="lazy" />
{:else if thumb?.kind === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="thumb-media" src={thumb.src + '#t=0.1'} muted playsinline preload="metadata"></video>
{:else}
<span class="thumb-slate">{entry.label}</span>
@@ -66,7 +66,7 @@
<div class="block">
<span class="k">{t('scriptDrawer.characters')}</span>
<div class="chips">
{#each scene.characters as c}
{#each scene.characters as c (c.id)}
<span class="chip">{c.name}</span>
{/each}
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
/**
* VideoEditWorkspace — modern Edit layout for Project → Video.
* Hero monitor + filmstrip + meta strip + docked Omni composer.
@@ -11,7 +12,7 @@
import ClipMonitor from './ClipMonitor.svelte';
import ClipSourceModal from './ClipSourceModal.svelte';
import JobInputsDrawer from './JobInputsDrawer.svelte';
import PromptPreviewModal from './PromptPreviewModal.svelte';
import './PromptPreviewModal.svelte';
import SceneFilmstrip, { type FilmstripClip } from './SceneFilmstrip.svelte';
import SceneScriptDrawer from './SceneScriptDrawer.svelte';
import { t } from '$lib/i18n.svelte';
@@ -87,7 +88,6 @@ import { t } from '$lib/i18n.svelte';
}
let {
scenes,
filmClips,
selectedClip,
selectedClipId,
@@ -266,7 +266,7 @@ import { t } from '$lib/i18n.svelte';
<div class="no-wf">
<p class="empty-title">{t('videoEdit.noWf')}</p>
<p class="muted">
{t('videoEdit.enableWfPre')} <a href="/settings?tab=workflows">{t('videoEdit.wfLink')}</a>{t('videoEdit.enableWfPost')}
{t('videoEdit.enableWfPre')} <a href={resolve('/settings?tab=workflows')}>{t('videoEdit.wfLink')}</a>{t('videoEdit.enableWfPost')}
</p>
</div>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { WorkbenchAsset, WorkbenchStage } from '$lib/api';
import { assetUrl } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
@@ -162,7 +163,7 @@
</div>
{#if asset.description}<p class="asset-desc">{asset.description}</p>{/if}
{#if asset.job?.status === 'done' && asset.job.output}
<a class="asset-shot" href={assetUrl(asset.job.output) ?? '#'} target="_blank" rel="noopener">
<a class="asset-shot" href={assetUrl(asset.job.output) ?? '#'} target="_blank" rel="noopener external">
<img src={assetUrl(asset.job.output) ?? ''} alt={asset.name} loading="lazy" />
</a>
{/if}
@@ -174,7 +175,7 @@
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
href={resolve(deskHref({
kind: 'image',
projectId,
stage: 'assets',
@@ -186,7 +187,7 @@
negative: asset.negative_prompt || undefined,
sourceKind: 'asset',
sourceId: asset.id,
})}
}))}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { VideoShot, WorkbenchStage } from '$lib/api';
import { assetUrl } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
@@ -153,6 +154,7 @@
</div>
{#if shot.description}<p class="vshot-desc">{shot.description}</p>{/if}
{#if shot.job?.status === 'done' && shot.job.output}
<!-- svelte-ignore a11y_media_has_caption (生成产物暂无字幕资产,保留声音与手动播放;字幕无障碍欠缺仍存在。) -->
<video
class="vshot-video"
src={assetUrl(shot.job.output) ?? undefined}
@@ -165,7 +167,7 @@
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
href={resolve(deskHref({
kind: 'video',
projectId,
stage: 'shot',
@@ -174,7 +176,7 @@
durationSec: shot.duration_sec,
sourceKind: 'shot',
sourceId: shot.id,
})}
}))}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
@@ -52,7 +52,7 @@
</script>
<ol class="stepper" data-testid="workbench-stepper">
{#each ORDER as key, i}
{#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}
@@ -198,6 +198,7 @@
function toggleIssue(f: WorkflowConvertFileReport) {
const key = rk(f);
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Mutate an isolated copy then assign expandedIssues once; rendering observes the state replacement, not intermediate Set writes.
const next = new Set(expandedIssues);
if (next.has(key)) next.delete(key);
else next.add(key);
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { onMount } from "svelte";
import { storageGet, storageSet } from "$lib/storage";
import { isWorkflowRunnable, workflowState } from "$lib/workflowState";
@@ -234,6 +235,7 @@
);
function toggleRow(id: number, on: boolean) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Keep copy/mutate/assign selection semantics: row toggle publishes selected only after the working copy is complete.
const next = new Set(selected);
if (on) next.add(id);
else next.delete(id);
@@ -241,6 +243,7 @@
}
function toggleSelectVisible() {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Keep copy/mutate/assign selection semantics: visible rows are added/removed before publishing selected once.
const next = new Set(selected);
if (allVisibleSelected) {
for (const r of visible) next.delete(r.id);
@@ -318,6 +321,7 @@
mutationFn: (id: number) => workflows.delete(id),
onSuccess: (_res, id) => {
invalidateWorkflows();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Delete success removes the ID from a working copy, then replaces selected; preserve the existing publication boundary.
const next = new Set(selected);
next.delete(id);
selected = next;
@@ -634,7 +638,7 @@
catFilter = "all";
}}>全部<small>{rows.length}</small></button
>
{#each [["image", "图片生成"], ["video", "视频生成"]] as [value, label]}
{#each [["image", "图片生成"], ["video", "视频生成"]] as [value, label] (value)}
<button
class="mg-filter"
class:on={kindFilter === value}
@@ -646,7 +650,7 @@
></button
>
{/each}
{#each categories.filter((c) => c !== "image" && c !== "video") as c}<button
{#each categories.filter((c) => c !== "image" && c !== "video") as c (c)}<button
class="mg-filter"
class:on={catFilter === c}
onclick={() => {
@@ -664,7 +668,7 @@
onclick={() => (familyFilter = "all")}
>全部模型<small>{rows.length}</small></button
>
{#each families as f}<button
{#each families as f (f)}<button
class="mg-filter"
class:on={familyFilter === f}
onclick={() => (familyFilter = f)}
@@ -732,7 +736,7 @@
/></label
>
<div class="mg-seg workflow-state" aria-label="工作流状态">
{#each [["all", "全部"], ["enabled", "可运行"], ["pending", "待配置"], ["problem", "有问题"]] as [value, label]}<button
{#each [["all", "全部"], ["enabled", "可运行"], ["pending", "待配置"], ["problem", "有问题"]] as [value, label] (value)}<button
class:on={statusFilter === value}
aria-pressed={statusFilter === value}
onclick={() => (statusFilter = value as typeof statusFilter)}
@@ -905,7 +909,7 @@
});
}}
><option value="">待确认</option
>{#each WORKFLOW_USAGES as u}<option value={u}
>{#each WORKFLOW_USAGES as u (u)}<option value={u}
>{usageLabel(u)}</option
>{/each}</select
><Button
@@ -926,7 +930,7 @@
<h3>输入映射 <span class="mg-dim">· 节点 → 创作台控件</span></h3>
{#if fullPending[row.id]}<p class="mg-dim">
正在读取输入…
</p>{:else if full}{#each full.input_schema as input}<div
</p>{:else if full}{#each full.input_schema as input (input.nodeId)}<div
class="mg-map"
>
<span class="a"
@@ -951,7 +955,7 @@
endpointId: e.currentTarget.value || null,
})}
><option value="">跟随默认连接</option
>{#each endpoints as ep}<option value={ep.id}>{ep.name}</option
>{#each endpoints as ep (ep.id)}<option value={ep.id}>{ep.name}</option
>{/each}</select
><Switch
checked={row.is_enabled}
@@ -971,7 +975,7 @@
<div class="mg-row">
{#if isWorkflowRunnable(row)}<a
class="a-btn secondary sm"
href={"/" + row.kind + "?wf=" + row.id}>在创作台打开</a
href={resolve(`/${row.kind}?wf=${row.id}`)}>在创作台打开</a
>{/if}<Button
size="sm"
loading={openingTestId === row.id}
+8
View File
@@ -0,0 +1,8 @@
/** Preserve the exact query/hash while keeping the destination typed as /create. */
export function createHref(
url: Pick<URL, "search" | "hash">,
): "/create" | `/create?${string}` | `/create#${string}` {
if (url.search) return `/create?${url.search.slice(1)}${url.hash}`;
if (url.hash) return `/create#${url.hash.slice(1)}`;
return "/create";
}
+4 -4
View File
@@ -5,8 +5,8 @@
* that already carry a timezone marker pass through unchanged.
*/
export function parseDbTs(raw: string | null | undefined): number {
if (!raw) return NaN;
const s = raw.trim();
if (/[zZ]$|[+-]\d{2}:?\d{2}$/.test(s)) return Date.parse(s);
return Date.parse(s.endsWith('Z') ? s : `${s.replace(' ', 'T')}Z`);
if (!raw) return NaN;
const s = raw.trim();
if (/[zZ]$|[+-]\d{2}:?\d{2}$/.test(s)) return Date.parse(s);
return Date.parse(s.endsWith("Z") ? s : `${s.replace(" ", "T")}Z`);
}
+54 -47
View File
@@ -3,71 +3,78 @@
* Used so Story UI can show the beat budget before drafting.
*/
export function estimateTargetSeconds(targetDuration: string | null | undefined): number {
const text = (targetDuration || '').trim().toLowerCase();
if (!text) return 30;
export function estimateTargetSeconds(
targetDuration: string | null | undefined,
): number {
const text = (targetDuration || "").trim().toLowerCase();
if (!text) return 30;
const scenesM = text.match(/(\d+)\s*scenes?/);
if (scenesM && !/min|sec/.test(text)) {
return Math.max(30, Number(scenesM[1]) * 8);
}
const scenesM = text.match(/(\d+)\s*scenes?/);
if (scenesM && !/min|sec/.test(text)) {
return Math.max(30, Number(scenesM[1]) * 8);
}
let minutes = 0;
let seconds = 0;
for (const m of text.matchAll(/(\d+(?:\.\d+)?)\s*(min|mins|minutes?|m)\b/g)) {
minutes += Number(m[1]);
}
for (const m of text.matchAll(/(\d+(?:\.\d+)?)\s*(sec|secs|seconds?|s)\b/g)) {
seconds += Number(m[1]);
}
if (minutes || seconds) return Math.max(15, Math.round(minutes * 60 + seconds));
let minutes = 0;
let seconds = 0;
for (const m of text.matchAll(/(\d+(?:\.\d+)?)\s*(min|mins|minutes?|m)\b/g)) {
minutes += Number(m[1]);
}
for (const m of text.matchAll(/(\d+(?:\.\d+)?)\s*(sec|secs|seconds?|s)\b/g)) {
seconds += Number(m[1]);
}
if (minutes || seconds)
return Math.max(15, Math.round(minutes * 60 + seconds));
const bare = text.match(/\b(\d+(?:\.\d+)?)\b/);
if (bare) {
const n = Number(bare[1]);
if (text.includes('medium') || n >= 2) {
if (n <= 30) return Math.round(n * 60);
}
if (n <= 180) return Math.round(n);
}
const bare = text.match(/\b(\d+(?:\.\d+)?)\b/);
if (bare) {
const n = Number(bare[1]);
if (text.includes("medium") || n >= 2) {
if (n <= 30) return Math.round(n * 60);
}
if (n <= 180) return Math.round(n);
}
if (text.includes('medium')) return 120;
if (text.includes('short') || text.includes('brief')) return 30;
if (text.includes('long') || text.includes('feature')) return 180;
return 60;
if (text.includes("medium")) return 120;
if (text.includes("short") || text.includes("brief")) return 30;
if (text.includes("long") || text.includes("feature")) return 180;
return 60;
}
/** ~12s narrative weight per beat; capped at 60. */
export function recommendBeatCount(targetDuration: string | null | undefined): number {
const secs = estimateTargetSeconds(targetDuration);
return Math.max(4, Math.min(60, Math.round(secs / 12)));
export function recommendBeatCount(
targetDuration: string | null | undefined,
): number {
const secs = estimateTargetSeconds(targetDuration);
return Math.max(4, Math.min(60, Math.round(secs / 12)));
}
export function recommendSceneCount(targetDuration: string | null | undefined): number {
const secs = estimateTargetSeconds(targetDuration);
return Math.max(4, Math.min(90, Math.round(secs / 7)));
export function recommendSceneCount(
targetDuration: string | null | undefined,
): number {
const secs = estimateTargetSeconds(targetDuration);
return Math.max(4, Math.min(90, Math.round(secs / 7)));
}
/** Same user prompt the backend sends to the LLM (for transparency). */
export function buildStoryDraftPromptPreview(args: {
title: string;
idea: string;
genre: string;
tone: string;
targetDuration: string;
title: string;
idea: string;
genre: string;
tone: string;
targetDuration: string;
}): string {
const secs = estimateTargetSeconds(args.targetDuration);
const beatN = recommendBeatCount(args.targetDuration);
return `Create a story brief for an AI-generated video.
const secs = estimateTargetSeconds(args.targetDuration);
const beatN = recommendBeatCount(args.targetDuration);
return `Create a story brief for an AI-generated video.
required_beat_count: ${beatN}
estimated_runtime_seconds: ${secs}
target_duration_text: ${args.targetDuration || 'short (~30 seconds)'}
target_duration_text: ${args.targetDuration || "short (~30 seconds)"}
Title: ${args.title || 'Untitled'}
Idea: ${args.idea || 'No idea provided.'}
Genre: ${args.genre || 'Not specified'}
Tone: ${args.tone || 'cinematic, atmospheric'}
Title: ${args.title || "Untitled"}
Idea: ${args.idea || "No idea provided."}
Genre: ${args.genre || "Not specified"}
Tone: ${args.tone || "cinematic, atmospheric"}
=== HARD CONSTRAINTS (non-negotiable) ===
1. The JSON field "beats" MUST contain EXACTLY ${beatN} objects.
+36 -25
View File
@@ -1,33 +1,44 @@
import { t } from '$lib/i18n.svelte';
import { t } from "$lib/i18n.svelte";
export type EndpointKind = 'local' | 'lan' | 'cloud';
export type EndpointKind = "local" | "lan" | "cloud";
/** 本机 / 局域网 / 云端 by host — drives the blue (local, LAN) vs purple (cloud) tags (§2.1). */
export function classifyEndpoint(rawUrl: string): EndpointKind {
try {
const u = new URL(rawUrl.trim());
const h = u.hostname.toLowerCase();
if (h === 'localhost' || h === '::1' || h.startsWith('127.')) return 'local';
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (m) {
const a = Number(m[1]);
const b = Number(m[2]);
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) return 'lan';
if (a === 169 && b === 254) return 'lan';
return 'cloud';
}
if (!h.includes('.') || h.endsWith('.local') || h.endsWith('.lan') || h.endsWith('.internal'))
return 'lan';
return 'cloud';
} catch {
return 'cloud';
}
try {
const u = new URL(rawUrl.trim());
const h = u.hostname.toLowerCase();
if (h === "localhost" || h === "::1" || h.startsWith("127."))
return "local";
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (m) {
const a = Number(m[1]);
const b = Number(m[2]);
if (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
)
return "lan";
if (a === 169 && b === 254) return "lan";
return "cloud";
}
if (
!h.includes(".") ||
h.endsWith(".local") ||
h.endsWith(".lan") ||
h.endsWith(".internal")
)
return "lan";
return "cloud";
} catch {
return "cloud";
}
}
export function endpointTypeLabel(kind: EndpointKind): string {
return kind === 'local'
? t('models.typeLocal')
: kind === 'lan'
? t('models.typeLan')
: t('models.typeCloud');
return kind === "local"
? t("models.typeLocal")
: kind === "lan"
? t("models.typeLan")
: t("models.typeCloud");
}
+74 -70
View File
@@ -1,7 +1,7 @@
export type CalliopeEvent = {
type: string;
data: Record<string, unknown>;
ts: string;
type: string;
data: Record<string, unknown>;
ts: string;
};
/**
@@ -10,80 +10,84 @@ export type CalliopeEvent = {
* - `open` — stream established (EventSource.onopen fired)
* - `reconnecting` — stream dropped; the 3s re-attach loop is running
*/
export type EventConnectionState = 'connecting' | 'open' | 'reconnecting';
export type EventConnectionState = "connecting" | "open" | "reconnecting";
type Listener = (event: CalliopeEvent) => void;
type StateListener = (state: EventConnectionState) => void;
export function connectEvents(
onEvent: Listener,
onState?: StateListener,
onEvent: Listener,
onState?: StateListener,
): (signal?: AbortSignal) => void {
let es: EventSource | null = null;
let closed = false;
let firstAttach = true;
let everOpened = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let es: EventSource | null = null;
let closed = false;
let firstAttach = true;
let everOpened = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
const attach = () => {
if (closed) return;
onState?.(firstAttach ? 'connecting' : 'reconnecting');
firstAttach = false;
es = new EventSource('/api/events');
es.onopen = () => {
onState?.('open');
// Reconnected after a drop: everything missed while the stream was
// down is gone forever (no replay), so ask the page to refetch.
// Listeners gate on `everOpened` so the very first open is a no-op.
if (everOpened) {
onEvent({ type: 'events.resync', data: {}, ts: new Date().toISOString() });
}
everOpened = true;
};
const handler = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as CalliopeEvent;
onEvent(parsed);
} catch {
/* ignore */
}
};
// Listen to named events + default message
[
'agent.thinking',
'agent.session.updated',
'agent.message',
'agent.token',
'agent.tool',
'agent.plan',
'agent.task',
'job.created',
'job.started',
'job.progress',
'job.completed',
'job.failed',
'job.deleted',
'asset.ready',
'story.ready',
'canvas.updated',
'shot.updated',
'shot.capture.saved',
].forEach((name) => es!.addEventListener(name, handler as EventListener));
es.onmessage = handler;
es.onerror = () => {
onState?.('reconnecting');
es?.close();
if (!closed) {
reconnectTimer = setTimeout(attach, 3000);
}
};
};
const attach = () => {
if (closed) return;
onState?.(firstAttach ? "connecting" : "reconnecting");
firstAttach = false;
es = new EventSource("/api/events");
es.onopen = () => {
onState?.("open");
// Reconnected after a drop: everything missed while the stream was
// down is gone forever (no replay), so ask the page to refetch.
// Listeners gate on `everOpened` so the very first open is a no-op.
if (everOpened) {
onEvent({
type: "events.resync",
data: {},
ts: new Date().toISOString(),
});
}
everOpened = true;
};
const handler = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as CalliopeEvent;
onEvent(parsed);
} catch {
/* ignore */
}
};
// Listen to named events + default message
[
"agent.thinking",
"agent.session.updated",
"agent.message",
"agent.token",
"agent.tool",
"agent.plan",
"agent.task",
"job.created",
"job.started",
"job.progress",
"job.completed",
"job.failed",
"job.deleted",
"asset.ready",
"story.ready",
"canvas.updated",
"shot.updated",
"shot.capture.saved",
].forEach((name) => es!.addEventListener(name, handler as EventListener));
es.onmessage = handler;
es.onerror = () => {
onState?.("reconnecting");
es?.close();
if (!closed) {
reconnectTimer = setTimeout(attach, 3000);
}
};
};
attach();
attach();
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
es?.close();
};
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
es?.close();
};
}
+18 -18
View File
@@ -1,26 +1,26 @@
// Maps backend-stored English values to i18n keys for display labels.
export const GENRE_KEYS: Record<string, string> = {
'Adventure / Mystery': 'genres.adventure',
Drama: 'genres.drama',
'Sci-Fi': 'genres.scifi',
Fantasy: 'genres.fantasy',
Horror: 'genres.horror',
Romance: 'genres.romance',
Thriller: 'genres.thriller',
"Adventure / Mystery": "genres.adventure",
Drama: "genres.drama",
"Sci-Fi": "genres.scifi",
Fantasy: "genres.fantasy",
Horror: "genres.horror",
Romance: "genres.romance",
Thriller: "genres.thriller",
};
export const TONE_KEYS: Record<string, string> = {
'Cinematic, atmospheric': 'tones.cinematic',
'Dark, tense': 'tones.dark',
'Whimsical, warm': 'tones.whimsical',
'Gritty, realistic': 'tones.gritty',
'Epic, sweeping': 'tones.epic',
"Cinematic, atmospheric": "tones.cinematic",
"Dark, tense": "tones.dark",
"Whimsical, warm": "tones.whimsical",
"Gritty, realistic": "tones.gritty",
"Epic, sweeping": "tones.epic",
};
export const DURATION_KEYS: Record<string, string> = {
'30 seconds': 'durations.30',
'1 minute': 'durations.1m',
'2 minutes': 'durations.2m',
'5 minutes': 'durations.5m',
'10 minutes': 'durations.10m',
};
"30 seconds": "durations.30",
"1 minute": "durations.1m",
"2 minutes": "durations.2m",
"5 minutes": "durations.5m",
"10 minutes": "durations.10m",
};
+42 -28
View File
@@ -1,47 +1,61 @@
import { de } from './i18n/de';
import { en } from './i18n/en';
import { es } from './i18n/es';
import { fr } from './i18n/fr';
import { ja } from './i18n/ja';
import { ko } from './i18n/ko';
import { zh } from './i18n/zh';
import { de } from "./i18n/de";
import { en } from "./i18n/en";
import { es } from "./i18n/es";
import { fr } from "./i18n/fr";
import { ja } from "./i18n/ja";
import { ko } from "./i18n/ko";
import { zh } from "./i18n/zh";
import { storageGet, storageSet } from './storage';
import { storageGet, storageSet } from "./storage";
export type Language = 'en' | 'zh' | 'es' | 'fr' | 'de' | 'ja' | 'ko';
export type Language = "en" | "zh" | "es" | "fr" | "de" | "ja" | "ko";
export type Dict = typeof en;
const dictionaries: Record<Language, Record<string, string>> = { en, zh, es, fr, de, ja, ko };
const dictionaries: Record<Language, Record<string, string>> = {
en,
zh,
es,
fr,
de,
ja,
ko,
};
const STORAGE_KEY = 'calliope-lang';
const STORAGE_KEY = "calliope-lang";
function isLanguage(value: string | null): value is Language {
return value !== null && Object.hasOwn(dictionaries, value);
return value !== null && Object.hasOwn(dictionaries, value);
}
function initialLanguage(): Language {
// Validated against the registered dictionaries — a stored value naming a
// language this build no longer ships must not blank the UI. This runs on
// the server too, where storage is absent and we render the default.
const stored = storageGet(STORAGE_KEY);
return isLanguage(stored) ? stored : 'zh';
// Validated against the registered dictionaries — a stored value naming a
// language this build no longer ships must not blank the UI. This runs on
// the server too, where storage is absent and we render the default.
const stored = storageGet(STORAGE_KEY);
return isLanguage(stored) ? stored : "zh";
}
export const language = $state<{ current: Language }>({ current: initialLanguage() });
export const language = $state<{ current: Language }>({
current: initialLanguage(),
});
export function setLanguage(lang: Language) {
language.current = lang;
storageSet(STORAGE_KEY, lang);
language.current = lang;
storageSet(STORAGE_KEY, lang);
}
/** Look up a UI string. Falls back to English, then to the key itself. */
export function t(key: string, vars?: Record<string, string | number>): string {
let s: string = dictionaries[language.current][key] ?? (en as Record<string, string>)[key] ?? key;
if (vars) {
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
// ponytail: count 语义占位统一为 {n};调用方传 count 即命中
if (vars.count !== undefined) s = s.replaceAll('{n}', String(vars.count));
}
return s;
}
let s: string =
dictionaries[language.current][key] ??
(en as Record<string, string>)[key] ??
key;
if (vars) {
for (const [k, v] of Object.entries(vars))
s = s.replaceAll(`{${k}}`, String(v));
// ponytail: count 语义占位统一为 {n};调用方传 count 即命中
if (vars.count !== undefined) s = s.replaceAll("{n}", String(vars.count));
}
return s;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+150 -144
View File
@@ -1,6 +1,6 @@
import { SvelteMap } from 'svelte/reactivity';
import type { CalliopeEvent } from './events';
import { t } from './i18n.svelte';
import { SvelteMap } from "svelte/reactivity";
import type { CalliopeEvent } from "./events";
import { t } from "./i18n.svelte";
/**
* Live per-job render progress, fed by the `/api/events` SSE stream.
@@ -28,20 +28,20 @@ import { t } from './i18n.svelte';
*/
export type JobProgressEntry = {
/** 0–100. Reaches 100 only via job.completed. */
progress: number;
/** Latest human-readable status line from the worker. */
message?: string;
/** Worker phase from job.progress (comfy_queued / comfy_running …). */
phase?: string;
/** Prompts ahead of this one in ComfyUI's own queue (comfy_queued only). */
queueAhead?: number | null;
/** True once the job reached a terminal state (done/failed). */
final: boolean;
/** Terminal status when final: 'done' | 'failed'. */
status?: 'done' | 'failed';
/** Last update, ms epoch. */
updatedAt: number;
/** 0–100. Reaches 100 only via job.completed. */
progress: number;
/** Latest human-readable status line from the worker. */
message?: string;
/** Worker phase from job.progress (comfy_queued / comfy_running …). */
phase?: string;
/** Prompts ahead of this one in ComfyUI's own queue (comfy_queued only). */
queueAhead?: number | null;
/** True once the job reached a terminal state (done/failed). */
final: boolean;
/** Terminal status when final: 'done' | 'failed'. */
status?: "done" | "failed";
/** Last update, ms epoch. */
updatedAt: number;
};
/** Synthesized ticks never exceed this; only job.completed reports 100. */
@@ -51,156 +51,162 @@ const DONE_KEEP_MS = 6_000;
const FAILED_KEEP_MS = 10_000;
function num(v: unknown): number | null {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
return null;
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)))
return Number(v);
return null;
}
function str(v: unknown): string {
return typeof v === 'string' ? v : '';
return typeof v === "string" ? v : "";
}
/** Numeric progress from a payload, normalized to 0–100 (capped below 100). */
function progressValue(d: Record<string, unknown>): number | null {
const raw = num(d.progress ?? d.percent ?? d.value);
if (raw == null) return null;
const pct = raw <= 1 ? raw * 100 : raw;
return Math.min(TICK_CAP, Math.max(0, pct));
const raw = num(d.progress ?? d.percent ?? d.value);
if (raw == null) return null;
const pct = raw <= 1 ? raw * 100 : raw;
return Math.min(TICK_CAP, Math.max(0, pct));
}
class JobProgressStore {
private map = new SvelteMap<number, JobProgressEntry>();
/** Jobs seen via job.started and not yet terminal — tick attribution. */
private running = new Set<number>();
private lastStarted: number | null = null;
private cleanupTimers = new Map<number, ReturnType<typeof setTimeout>>();
private map = new SvelteMap<number, JobProgressEntry>();
/** Jobs seen via job.started and not yet terminal — tick attribution. */
private running = new Set<number>();
private lastStarted: number | null = null;
private cleanupTimers = new Map<number, ReturnType<typeof setTimeout>>();
/** Live entry for a job id (tracked — safe inside $derived / markup). */
get(jobId: number | null | undefined): JobProgressEntry | undefined {
if (jobId == null) return undefined;
return this.map.get(jobId);
}
/** Live entry for a job id (tracked — safe inside $derived / markup). */
get(jobId: number | null | undefined): JobProgressEntry | undefined {
if (jobId == null) return undefined;
return this.map.get(jobId);
}
handleEvent(ev: CalliopeEvent): void {
const d = ev.data ?? {};
switch (ev.type) {
case 'job.created': {
const id = num(d.job_id);
if (id == null) return;
this.cancelCleanup(id);
this.map.set(id, {
progress: 0,
message: str(d.message) || t('common.queued'),
final: false,
updatedAt: Date.now(),
});
break;
}
case 'job.started': {
const id = num(d.job_id);
if (id == null) return;
this.cancelCleanup(id);
this.running.add(id);
this.lastStarted = id;
const prev = this.map.get(id);
this.map.set(id, {
progress: prev?.progress ?? 0,
message: str(d.message) || t('common.running'),
final: false,
updatedAt: Date.now(),
});
break;
}
case 'job.progress': {
const id = this.resolveJobId(d);
if (id == null) return;
const prev = this.map.get(id);
const progress = progressValue(d) ?? this.synthesize(prev?.progress ?? 0);
this.map.set(id, {
progress,
message: str(d.message) || prev?.message,
phase: str(d.phase) || prev?.phase,
queueAhead: d.queue_ahead === undefined ? prev?.queueAhead : num(d.queue_ahead),
final: false,
updatedAt: Date.now(),
});
break;
}
case 'job.completed':
case 'job.failed': {
const id = num(d.job_id);
if (id == null) return;
this.running.delete(id);
if (this.lastStarted === id) this.lastStarted = null;
const failed = ev.type === 'job.failed';
const prev = this.map.get(id);
this.map.set(id, {
progress: failed ? (prev?.progress ?? 0) : 100,
message: str(d.error) || str(d.message) || prev?.message,
final: true,
status: failed ? 'failed' : 'done',
updatedAt: Date.now(),
});
this.scheduleCleanup(id, failed ? FAILED_KEEP_MS : DONE_KEEP_MS);
break;
}
case 'job.deleted': {
const id = num(d.job_id);
if (id == null) return;
this.running.delete(id);
if (this.lastStarted === id) this.lastStarted = null;
this.cancelCleanup(id);
this.map.delete(id);
break;
}
}
}
handleEvent(ev: CalliopeEvent): void {
const d = ev.data ?? {};
switch (ev.type) {
case "job.created": {
const id = num(d.job_id);
if (id == null) return;
this.cancelCleanup(id);
this.map.set(id, {
progress: 0,
message: str(d.message) || t("common.queued"),
final: false,
updatedAt: Date.now(),
});
break;
}
case "job.started": {
const id = num(d.job_id);
if (id == null) return;
this.cancelCleanup(id);
this.running.add(id);
this.lastStarted = id;
const prev = this.map.get(id);
this.map.set(id, {
progress: prev?.progress ?? 0,
message: str(d.message) || t("common.running"),
final: false,
updatedAt: Date.now(),
});
break;
}
case "job.progress": {
const id = this.resolveJobId(d);
if (id == null) return;
const prev = this.map.get(id);
const progress =
progressValue(d) ?? this.synthesize(prev?.progress ?? 0);
this.map.set(id, {
progress,
message: str(d.message) || prev?.message,
phase: str(d.phase) || prev?.phase,
queueAhead:
d.queue_ahead === undefined ? prev?.queueAhead : num(d.queue_ahead),
final: false,
updatedAt: Date.now(),
});
break;
}
case "job.completed":
case "job.failed": {
const id = num(d.job_id);
if (id == null) return;
this.running.delete(id);
if (this.lastStarted === id) this.lastStarted = null;
const failed = ev.type === "job.failed";
const prev = this.map.get(id);
this.map.set(id, {
progress: failed ? (prev?.progress ?? 0) : 100,
message: str(d.error) || str(d.message) || prev?.message,
final: true,
status: failed ? "failed" : "done",
updatedAt: Date.now(),
});
this.scheduleCleanup(id, failed ? FAILED_KEEP_MS : DONE_KEEP_MS);
break;
}
case "job.deleted": {
const id = num(d.job_id);
if (id == null) return;
this.running.delete(id);
if (this.lastStarted === id) this.lastStarted = null;
this.cancelCleanup(id);
this.map.delete(id);
break;
}
}
}
/**
* Explicit job_id wins; a tick without one goes to the only running job,
* else the most recently started one.
*/
private resolveJobId(d: Record<string, unknown>): number | null {
const explicit = num(d.job_id);
if (explicit != null) return explicit;
if (this.running.size === 1) return [...this.running][0];
if (this.lastStarted != null && this.running.has(this.lastStarted)) return this.lastStarted;
return null;
}
/**
* Explicit job_id wins; a tick without one goes to the only running job,
* else the most recently started one.
*/
private resolveJobId(d: Record<string, unknown>): number | null {
const explicit = num(d.job_id);
if (explicit != null) return explicit;
if (this.running.size === 1) return [...this.running][0];
if (this.lastStarted != null && this.running.has(this.lastStarted))
return this.lastStarted;
return null;
}
/** Asymptotic approach to TICK_CAP — feels live, never falsely completes. */
private synthesize(prev: number): number {
return prev + (TICK_CAP - prev) * 0.08;
}
/** Asymptotic approach to TICK_CAP — feels live, never falsely completes. */
private synthesize(prev: number): number {
return prev + (TICK_CAP - prev) * 0.08;
}
private scheduleCleanup(jobId: number, delayMs: number): void {
this.cancelCleanup(jobId);
this.cleanupTimers.set(
jobId,
setTimeout(() => {
this.cleanupTimers.delete(jobId);
this.map.delete(jobId);
}, delayMs),
);
}
private scheduleCleanup(jobId: number, delayMs: number): void {
this.cancelCleanup(jobId);
this.cleanupTimers.set(
jobId,
setTimeout(() => {
this.cleanupTimers.delete(jobId);
this.map.delete(jobId);
}, delayMs),
);
}
private cancelCleanup(jobId: number): void {
const timer = this.cleanupTimers.get(jobId);
if (timer) {
clearTimeout(timer);
this.cleanupTimers.delete(jobId);
}
}
private cancelCleanup(jobId: number): void {
const timer = this.cleanupTimers.get(jobId);
if (timer) {
clearTimeout(timer);
this.cleanupTimers.delete(jobId);
}
}
}
export const jobProgress = new JobProgressStore();
/** Feed one SSE event into the store. Safe to call for every event type. */
export function handleJobEvent(ev: CalliopeEvent): void {
jobProgress.handleEvent(ev);
jobProgress.handleEvent(ev);
}
/** Read the live entry for a job id (reactive via SvelteMap). */
export function progressFor(jobId: number | null | undefined): JobProgressEntry | undefined {
return jobProgress.get(jobId);
export function progressFor(
jobId: number | null | undefined,
): JobProgressEntry | undefined {
return jobProgress.get(jobId);
}
+77 -42
View File
@@ -1,15 +1,15 @@
import type { Job } from './comfy/types';
import { progressFor } from './jobProgress';
import { t } from './i18n.svelte';
import type { Job } from "./comfy/types";
import { progressFor } from "./jobProgress";
import { t } from "./i18n.svelte";
export type JobStatusTone = 'ok' | 'run' | 'queue' | 'fail';
export type JobStatusTone = "ok" | "run" | "queue" | "fail";
export interface JobStatusInfo {
tone: JobStatusTone;
/** Short label for status columns. */
label: string;
/** Full sentence for the task card / tooltip. */
detail: string;
tone: JobStatusTone;
/** Short label for status columns. */
label: string;
/** Full sentence for the task card / tooltip. */
detail: string;
}
/**
@@ -17,37 +17,72 @@ export interface JobStatusInfo {
* A dry-run placeholder or a done row without outputs never reads as done.
*/
export function jobStatusInfo(job: Job): JobStatusInfo {
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase ?? null;
const idSuffix = job.comfy_prompt_id ? t('jobst.idSuffix', { id: job.comfy_prompt_id }) : '';
switch (job.status) {
case 'done':
if (phase === 'dry_run')
return { tone: 'queue', label: t('jobst.dryRun'), detail: t('jobst.dryRunDetail') };
if (!(job.output_paths?.length ?? 0))
return { tone: 'fail', label: t('jobst.noOutput'), detail: t('jobst.noOutputDetail') };
return { tone: 'ok', label: t('jobst.done'), detail: t('jobst.doneDetail') + idSuffix };
case 'pending':
return { tone: 'queue', label: t('jobst.queued'), detail: t('jobst.queuedDetail') };
case 'running':
if (phase === 'comfy_queued') {
const ahead = live?.queueAhead;
const detail =
ahead != null && ahead > 0
? t('jobst.comfyQueuedAhead', { n: ahead })
: t('jobst.comfyQueuedDetail');
return { tone: 'queue', label: t('jobst.comfyQueued'), detail: detail + idSuffix };
}
if (phase === 'comfy_running')
return { tone: 'run', label: t('jobst.comfyRunning'), detail: t('jobst.comfyRunningDetail') + idSuffix };
if (phase === 'saving')
return { tone: 'run', label: t('jobst.saving'), detail: t('jobst.savingDetail') + idSuffix };
return { tone: 'run', label: t('jobst.connecting'), detail: t('jobst.connectingDetail') };
default:
return {
tone: 'fail',
label: job.error === 'cancelled' ? t('jobst.cancelled') : t('jobst.failed'),
detail: job.error ?? t('jobst.failed'),
};
}
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase ?? null;
const idSuffix = job.comfy_prompt_id
? t("jobst.idSuffix", { id: job.comfy_prompt_id })
: "";
switch (job.status) {
case "done":
if (phase === "dry_run")
return {
tone: "queue",
label: t("jobst.dryRun"),
detail: t("jobst.dryRunDetail"),
};
if (!(job.output_paths?.length ?? 0))
return {
tone: "fail",
label: t("jobst.noOutput"),
detail: t("jobst.noOutputDetail"),
};
return {
tone: "ok",
label: t("jobst.done"),
detail: t("jobst.doneDetail") + idSuffix,
};
case "pending":
return {
tone: "queue",
label: t("jobst.queued"),
detail: t("jobst.queuedDetail"),
};
case "running":
if (phase === "comfy_queued") {
const ahead = live?.queueAhead;
const detail =
ahead != null && ahead > 0
? t("jobst.comfyQueuedAhead", { n: ahead })
: t("jobst.comfyQueuedDetail");
return {
tone: "queue",
label: t("jobst.comfyQueued"),
detail: detail + idSuffix,
};
}
if (phase === "comfy_running")
return {
tone: "run",
label: t("jobst.comfyRunning"),
detail: t("jobst.comfyRunningDetail") + idSuffix,
};
if (phase === "saving")
return {
tone: "run",
label: t("jobst.saving"),
detail: t("jobst.savingDetail") + idSuffix,
};
return {
tone: "run",
label: t("jobst.connecting"),
detail: t("jobst.connectingDetail"),
};
default:
return {
tone: "fail",
label:
job.error === "cancelled" ? t("jobst.cancelled") : t("jobst.failed"),
detail: job.error ?? t("jobst.failed"),
};
}
}
@@ -62,6 +62,7 @@ export function videoBaseName(src: string): string {
name = "";
}
name = name.replace(/\.[a-z0-9]{1,5}$/i, "");
// eslint-disable-next-line no-control-regex -- U+0000–001F are intentionally rejected in filenames. Keep one underscore per contiguous invalid run, including mixed punctuation/control characters.
name = name.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, "_").trim();
return name || "video";
}
+19 -19
View File
@@ -1,38 +1,38 @@
import { SvelteSet } from 'svelte/reactivity';
import type { Job } from '$lib/comfy/types';
import { SvelteSet } from "svelte/reactivity";
import type { Job } from "$lib/comfy/types";
/**
* Output files a done job claims but the asset endpoint could not serve
* (spec-v2 §3.4 红/琥珀界线): the backend task status stays the single
* verdict for red — a FAILED job is red no matter what. Amber means the
* job SUCCEEDED and only its product file is gone (cleaned up, moved,
* storage hiccup). We learn that the only way the frontend can: the
* media element itself failed to load. A later successful load clears
* the mark.
*/
* Output files a done job claims but the asset endpoint could not serve
* (spec-v2 §3.4 红/琥珀界线): the backend task status stays the single
* verdict for red — a FAILED job is red no matter what. Amber means the
* job SUCCEEDED and only its product file is gone (cleaned up, moved,
* storage hiccup). We learn that the only way the frontend can: the
* media element itself failed to load. A later successful load clears
* the mark.
*/
const missing = new SvelteSet<string>();
/** Mark one output path as unreachable (img/video onerror). */
export function markOutputMissing(path: string): void {
if (path) missing.add(path);
if (path) missing.add(path);
}
/** Clear the mark when the same path loads fine after all. */
export function clearOutputMissing(path: string): void {
missing.delete(path);
missing.delete(path);
}
/** Paths of this job's outputs that are currently unreachable. */
export function missingOutputsOf(job: Job | null | undefined): string[] {
if (!job || job.status !== 'done' || !(job.output_paths?.length ?? 0))
return [];
return job.output_paths.filter((p) => missing.has(p));
if (!job || job.status !== "done" || !(job.output_paths?.length ?? 0))
return [];
return job.output_paths.filter((p) => missing.has(p));
}
/**
* The take reads as 文件缺失 when it succeeded but at least one claimed
* product file cannot be fetched.
*/
* The take reads as 文件缺失 when it succeeded but at least one claimed
* product file cannot be fetched.
*/
export function isFileMissing(job: Job | null | undefined): boolean {
return missingOutputsOf(job).length > 0;
return missingOutputsOf(job).length > 0;
}
+60 -60
View File
@@ -6,82 +6,82 @@
*/
export type CharacterPromptFields = {
name?: string | null;
role?: string | null;
age?: string | null;
appearance?: string | null;
personality?: string | null;
name?: string | null;
role?: string | null;
age?: string | null;
appearance?: string | null;
personality?: string | null;
};
export type LocationPromptFields = {
name?: string | null;
description?: string | null;
name?: string | null;
description?: string | null;
};
export type ItemPromptFields = {
name?: string | null;
description?: string | null;
name?: string | null;
description?: string | null;
};
export function characterSheetTemplate(c: CharacterPromptFields): string {
const name = (c.name || 'Unnamed').trim();
const role = (c.role || 'character').trim();
const age = (c.age || 'unspecified age').trim();
const appearance = (c.appearance || 'no appearance notes yet').trim();
const personality = (c.personality || '').trim();
const personalityLine = personality
? `Personality cues (visual only): ${personality}\n`
: '';
return (
`CHARACTER SHEET — ${name}\n` +
`Role: ${role}. Age: ${age}.\n` +
`Appearance: ${appearance}\n` +
personalityLine +
`\n` +
`Layout: single cinematic character reference sheet on a clean neutral backdrop, ` +
`multiple panels — front full body, side full body, back full body, three-quarter, ` +
`and a head close-up. Same face, hair, body proportions, and outfit in every panel. ` +
`Neutral standing pose, even studio lighting, high detail, no text watermarks, ` +
`no extra characters.`
);
const name = (c.name || "Unnamed").trim();
const role = (c.role || "character").trim();
const age = (c.age || "unspecified age").trim();
const appearance = (c.appearance || "no appearance notes yet").trim();
const personality = (c.personality || "").trim();
const personalityLine = personality
? `Personality cues (visual only): ${personality}\n`
: "";
return (
`CHARACTER SHEET — ${name}\n` +
`Role: ${role}. Age: ${age}.\n` +
`Appearance: ${appearance}\n` +
personalityLine +
`\n` +
`Layout: single cinematic character reference sheet on a clean neutral backdrop, ` +
`multiple panels — front full body, side full body, back full body, three-quarter, ` +
`and a head close-up. Same face, hair, body proportions, and outfit in every panel. ` +
`Neutral standing pose, even studio lighting, high detail, no text watermarks, ` +
`no extra characters.`
);
}
export function characterPortraitTemplate(c: CharacterPromptFields): string {
const name = (c.name || 'Unnamed').trim();
const role = (c.role || 'character').trim();
const age = (c.age || 'unspecified age').trim();
const appearance = (c.appearance || 'no appearance notes yet').trim();
return (
`CHARACTER PORTRAIT — ${name}\n` +
`Role: ${role}. Age: ${age}.\n` +
`Appearance: ${appearance}\n` +
`\n` +
`Shot: cinematic head-and-shoulders portrait, eye-level, soft key light, ` +
`shallow depth of field, sharp facial features, consistent wardrobe collar/shoulders, ` +
`plain muted background, photoreal or high-end concept art, no text.`
);
const name = (c.name || "Unnamed").trim();
const role = (c.role || "character").trim();
const age = (c.age || "unspecified age").trim();
const appearance = (c.appearance || "no appearance notes yet").trim();
return (
`CHARACTER PORTRAIT — ${name}\n` +
`Role: ${role}. Age: ${age}.\n` +
`Appearance: ${appearance}\n` +
`\n` +
`Shot: cinematic head-and-shoulders portrait, eye-level, soft key light, ` +
`shallow depth of field, sharp facial features, consistent wardrobe collar/shoulders, ` +
`plain muted background, photoreal or high-end concept art, no text.`
);
}
export function locationReferenceTemplate(loc: LocationPromptFields): string {
const name = (loc.name || 'Unnamed place').trim();
const description = (loc.description || 'no description yet').trim();
return (
`ENVIRONMENT REFERENCE — ${name}\n` +
`Description: ${description}\n` +
`\n` +
`Shot: wide establishing concept art, cinematic atmosphere, clear readable space ` +
`for characters to stand in, consistent lighting and materials, no people, no text.`
);
const name = (loc.name || "Unnamed place").trim();
const description = (loc.description || "no description yet").trim();
return (
`ENVIRONMENT REFERENCE — ${name}\n` +
`Description: ${description}\n` +
`\n` +
`Shot: wide establishing concept art, cinematic atmosphere, clear readable space ` +
`for characters to stand in, consistent lighting and materials, no people, no text.`
);
}
export function itemReferenceTemplate(item: ItemPromptFields): string {
const name = (item.name || 'Unnamed item').trim();
const description = (item.description || 'no description yet').trim();
return (
`ITEM REFERENCE — ${name}\n` +
`Description: ${description}\n` +
`\n` +
`Shot: single object on a clean neutral backdrop, centered, full object in ` +
`frame, consistent lighting and materials, high detail, no people, no text.`
);
const name = (item.name || "Unnamed item").trim();
const description = (item.description || "no description yet").trim();
return (
`ITEM REFERENCE — ${name}\n` +
`Description: ${description}\n` +
`\n` +
`Shot: single object on a clean neutral backdrop, centered, full object in ` +
`frame, consistent lighting and materials, high detail, no people, no text.`
);
}
+24 -24
View File
@@ -1,4 +1,4 @@
import { storageGet, storageSet } from './storage';
import { storageGet, storageSet } from "./storage";
/**
* 减少动态效果 (HEL-649 §五). The OS setting always wins (app.css also reacts
@@ -6,40 +6,40 @@ import { storageGet, storageSet } from './storage';
* otherwise the top-bar switch decides. The effective value is mirrored to
* `<html data-reduce="1">`, which is what every reduced-motion rule keys on.
*/
const STORAGE_KEY = 'aigc-reduce-motion';
const QUERY = '(prefers-reduced-motion: reduce)';
const STORAGE_KEY = "aigc-reduce-motion";
const QUERY = "(prefers-reduced-motion: reduce)";
export const reduceMotion = $state({ system: false, manual: false });
let stop: (() => void) | null = null;
function apply() {
document.documentElement.dataset.reduce =
reduceMotion.system || reduceMotion.manual ? '1' : '0';
document.documentElement.dataset.reduce =
reduceMotion.system || reduceMotion.manual ? "1" : "0";
}
/** Call once on the client (root layout). Returns the teardown. */
export function initReduceMotion(): () => void {
if (typeof window === 'undefined') return () => {};
stop?.();
const mq = window.matchMedia(QUERY);
reduceMotion.system = mq.matches;
reduceMotion.manual = storageGet(STORAGE_KEY) === '1';
apply();
const onChange = (e: MediaQueryListEvent) => {
reduceMotion.system = e.matches;
apply();
};
mq.addEventListener('change', onChange);
stop = () => mq.removeEventListener('change', onChange);
return () => {
stop?.();
stop = null;
};
if (typeof window === "undefined") return () => {};
stop?.();
const mq = window.matchMedia(QUERY);
reduceMotion.system = mq.matches;
reduceMotion.manual = storageGet(STORAGE_KEY) === "1";
apply();
const onChange = (e: MediaQueryListEvent) => {
reduceMotion.system = e.matches;
apply();
};
mq.addEventListener("change", onChange);
stop = () => mq.removeEventListener("change", onChange);
return () => {
stop?.();
stop = null;
};
}
export function setReduceMotion(on: boolean) {
reduceMotion.manual = on;
storageSet(STORAGE_KEY, on ? '1' : '0');
apply();
reduceMotion.manual = on;
storageSet(STORAGE_KEY, on ? "1" : "0");
apply();
}
+82 -82
View File
@@ -2,89 +2,89 @@
* Source: https://cn.lk666.ai/home?menu=zhinengti (story agent), 2026-10-14.
* No account imagery, private history, authentication data, or remote calls. */
export const agentStyles = [
{
name: '韩漫唯美',
image: 'hanmanweimei.jpg',
prompt: '细腻唯美的韩漫风格,柔和光线与人物情绪',
},
{
name: '日系动画',
image: 'rixidonghua.jpg',
prompt: '日系动画风格,清晰线条与富有层次的色彩',
},
{
name: '纯正国风',
image: 'chunzhengguofeng.jpg',
prompt: '国风水墨意境,传统色彩与写意构图',
},
{
name: '激燃极光',
image: 'jiranjiguang.jpg',
prompt: '强烈动感、鲜明对比与戏剧化光效',
},
{
name: '3D盲盒',
image: '3dmanghe.jpg',
prompt: '可爱的三维盲盒人物,圆润造型与柔和材质',
},
{
name: '趣味黏土',
image: 'quweiniantu.jpg',
prompt: '手工黏土质感,温暖色彩与趣味造型',
},
{
name: '美漫硬朗',
image: 'meimanyinglang.jpg',
prompt: '硬朗美漫风格,强烈阴影与有力线条',
},
{
name: '写实画报',
image: 'xieshihuabao.jpg',
prompt: '写实电影画报风格,自然质感与精致光线',
},
{
name: '治愈绘本',
image: 'zhiyuhuiben.jpg',
prompt: '温暖治愈的手绘绘本,柔和色彩与亲切人物',
},
{
name: '冒险动漫',
image: 'haizeiwangfengge.jpg',
prompt: '明快的冒险动漫风格,夸张表情与充满活力的画面',
},
{
name: "韩漫唯美",
image: "hanmanweimei.jpg",
prompt: "细腻唯美的韩漫风格,柔和光线与人物情绪",
},
{
name: "日系动画",
image: "rixidonghua.jpg",
prompt: "日系动画风格,清晰线条与富有层次的色彩",
},
{
name: "纯正国风",
image: "chunzhengguofeng.jpg",
prompt: "国风水墨意境,传统色彩与写意构图",
},
{
name: "激燃极光",
image: "jiranjiguang.jpg",
prompt: "强烈动感、鲜明对比与戏剧化光效",
},
{
name: "3D盲盒",
image: "3dmanghe.jpg",
prompt: "可爱的三维盲盒人物,圆润造型与柔和材质",
},
{
name: "趣味黏土",
image: "quweiniantu.jpg",
prompt: "手工黏土质感,温暖色彩与趣味造型",
},
{
name: "美漫硬朗",
image: "meimanyinglang.jpg",
prompt: "硬朗美漫风格,强烈阴影与有力线条",
},
{
name: "写实画报",
image: "xieshihuabao.jpg",
prompt: "写实电影画报风格,自然质感与精致光线",
},
{
name: "治愈绘本",
image: "zhiyuhuiben.jpg",
prompt: "温暖治愈的手绘绘本,柔和色彩与亲切人物",
},
{
name: "冒险动漫",
image: "haizeiwangfengge.jpg",
prompt: "明快的冒险动漫风格,夸张表情与充满活力的画面",
},
];
export const agentThemes: Record<
string,
{ title: string; hint: string; color: string; hue: number }
string,
{ title: string; hint: string; color: string; hue: number }
> = {
script: {
title: '把一句灵感,\n写成一个好故事。',
hint: '打磨人物、冲突与故事节奏',
color: '#f5b042',
hue: 0,
},
style: {
title: '让脑海里的画面,\n拥有自己的风格。',
hint: '确定画风、色彩与光线',
color: '#cba0ff',
hue: 20,
},
storyboard: {
title: '把故事的每一刻,\n放进恰当的镜头。',
hint: '拆解构图、动作与镜头节奏',
color: '#73ceee',
hue: 85,
},
assets: {
title: '人物、场景与细节,\n一起走进作品里。',
hint: '整理角色、场景与参考素材',
color: '#69d4bc',
hue: 145,
},
shot: {
title: '让静止的想象,\n有了流动的瞬间。',
hint: '镜头提示词、图片与视频工具',
color: '#ff95b0',
hue: 300,
},
script: {
title: "把一句灵感,\n写成一个好故事。",
hint: "打磨人物、冲突与故事节奏",
color: "#f5b042",
hue: 0,
},
style: {
title: "让脑海里的画面,\n拥有自己的风格。",
hint: "确定画风、色彩与光线",
color: "#cba0ff",
hue: 20,
},
storyboard: {
title: "把故事的每一刻,\n放进恰当的镜头。",
hint: "拆解构图、动作与镜头节奏",
color: "#73ceee",
hue: 85,
},
assets: {
title: "人物、场景与细节,\n一起走进作品里。",
hint: "整理角色、场景与参考素材",
color: "#69d4bc",
hue: 145,
},
shot: {
title: "让静止的想象,\n有了流动的瞬间。",
hint: "镜头提示词、图片与视频工具",
color: "#ff95b0",
hue: 300,
},
};
+101 -82
View File
@@ -1,98 +1,117 @@
/** Typed fetch helpers for the /api/shots surface. */
export interface Capture {
id: number;
composition_id: number;
kind: 'image' | 'video';
label: string | null;
file_path: string | null;
meta_json?: string | null;
meta?: Record<string, unknown> | null;
created_at: string;
id: number;
composition_id: number;
kind: "image" | "video";
label: string | null;
file_path: string | null;
meta_json?: string | null;
meta?: Record<string, unknown> | null;
created_at: string;
}
export interface GateRecordResult {
ok: boolean;
gate: 'brief' | 'cut';
gates: {
brief?: { approved_at: string; source?: string; note?: string | null } | null;
cut?: { approved_at: string; source?: string; note?: string | null } | null;
};
ok: boolean;
gate: "brief" | "cut";
gates: {
brief?: {
approved_at: string;
source?: string;
note?: string | null;
} | null;
cut?: { approved_at: string; source?: string; note?: string | null } | null;
};
}
export const shotApi = {
async listCaptures(compositionId: number): Promise<Capture[]> {
const resp = await fetch(`/api/shots/${compositionId}/captures`);
if (!resp.ok) return [];
return resp.json();
},
async listCaptures(compositionId: number): Promise<Capture[]> {
const resp = await fetch(`/api/shots/${compositionId}/captures`);
if (!resp.ok) return [];
return resp.json();
},
async uploadCapture(compositionId: number, dataUrl: string, label: string): Promise<Capture | null> {
const resp = await fetch(`/api/shots/${compositionId}/captures`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data_url: dataUrl, label }),
});
if (!resp.ok) return null;
return resp.json();
},
async uploadCapture(
compositionId: number,
dataUrl: string,
label: string,
): Promise<Capture | null> {
const resp = await fetch(`/api/shots/${compositionId}/captures`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data_url: dataUrl, label }),
});
if (!resp.ok) return null;
return resp.json();
},
async deleteCapture(compositionId: number, captureId: number): Promise<boolean> {
const resp = await fetch(`/api/shots/${compositionId}/captures/${captureId}`, {
method: 'DELETE',
});
return resp.ok;
},
async deleteCapture(
compositionId: number,
captureId: number,
): Promise<boolean> {
const resp = await fetch(
`/api/shots/${compositionId}/captures/${captureId}`,
{
method: "DELETE",
},
);
return resp.ok;
},
async setCaptureRequest(compositionId: number, label: string): Promise<void> {
await fetch(`/api/shots/${compositionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ capture_request_json: JSON.stringify({ label, source: 'ui' }) }),
});
},
async setCaptureRequest(compositionId: number, label: string): Promise<void> {
await fetch(`/api/shots/${compositionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
capture_request_json: JSON.stringify({ label, source: "ui" }),
}),
});
},
/** Every composition (any session) — used by project pickers to collect captures. */
async listCompositions(): Promise<Array<{ id: number; agent_session_id: number | null; title: string }>> {
const resp = await fetch('/api/shots');
if (!resp.ok) return [];
return resp.json();
},
/** Every composition (any session) — used by project pickers to collect captures. */
async listCompositions(): Promise<
Array<{ id: number; agent_session_id: number | null; title: string }>
> {
const resp = await fetch("/api/shots");
if (!resp.ok) return [];
return resp.json();
},
async deleteComposition(compositionId: number): Promise<boolean> {
const resp = await fetch(`/api/shots/${compositionId}`, { method: 'DELETE' });
return resp.ok;
},
/** Record Brief/Cut gate from the UI (source: 'ui'). Human at keyboard — no ask_user. */
async recordGate(
compositionId: number,
gate: 'brief' | 'cut',
opts: { note?: string; source?: string } = {},
): Promise<GateRecordResult> {
const resp = await fetch(`/api/shots/${compositionId}/gates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gate,
note: opts.note ?? null,
source: opts.source ?? 'ui',
}),
});
if (!resp.ok) {
let detail = `Gate record failed (${resp.status})`;
try {
const body = await resp.json();
detail =
typeof body?.detail === 'string'
? body.detail
: body?.detail?.message || body?.status || detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
return resp.json();
},
async deleteComposition(compositionId: number): Promise<boolean> {
const resp = await fetch(`/api/shots/${compositionId}`, {
method: "DELETE",
});
return resp.ok;
},
/** Record Brief/Cut gate from the UI (source: 'ui'). Human at keyboard — no ask_user. */
async recordGate(
compositionId: number,
gate: "brief" | "cut",
opts: { note?: string; source?: string } = {},
): Promise<GateRecordResult> {
const resp = await fetch(`/api/shots/${compositionId}/gates`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
gate,
note: opts.note ?? null,
source: opts.source ?? "ui",
}),
});
if (!resp.ok) {
let detail = `Gate record failed (${resp.status})`;
try {
const body = await resp.json();
detail =
typeof body?.detail === "string"
? body.detail
: body?.detail?.message || body?.status || detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
return resp.json();
},
};
@@ -22,5 +22,8 @@ export const COMPOSITION_PRESETS: CompositionPreset[] = [
export const DEFAULT_COMPOSITION_PRESET = COMPOSITION_PRESETS[0];
export function getCompositionPreset(id: string): CompositionPreset {
return COMPOSITION_PRESETS.find((preset) => preset.id === id) ?? DEFAULT_COMPOSITION_PRESET;
return (
COMPOSITION_PRESETS.find((preset) => preset.id === id) ??
DEFAULT_COMPOSITION_PRESET
);
}
@@ -12,7 +12,13 @@
import * as THREE from "three";
export type ShotSize = "wide" | "full" | "medium" | "mcu" | "closeup";
export type CameraAngle = "front" | "threeQuarterLeft" | "threeQuarterRight" | "profile" | "back" | "ots";
export type CameraAngle =
| "front"
| "threeQuarterLeft"
| "threeQuarterRight"
| "profile"
| "back"
| "ots";
export type Elevation = "eye" | "low" | "high";
/**
@@ -60,7 +66,9 @@ export function getCharacterAnchors(root: THREE.Object3D): CharacterAnchors {
const box = new THREE.Box3().setFromObject(root);
const feet = box.min.y;
const height = Math.max(box.max.y - feet, 0.01);
const forward = new THREE.Vector3(0, 0, 1).applyQuaternion(root.getWorldQuaternion(new THREE.Quaternion()));
const forward = new THREE.Vector3(0, 0, 1).applyQuaternion(
root.getWorldQuaternion(new THREE.Quaternion()),
);
return {
feet,
hip: feet + height * 0.5,
@@ -74,13 +82,14 @@ export function getCharacterAnchors(root: THREE.Object3D): CharacterAnchors {
}
/** Vertical frame span, as fractions of character height measured from the feet. */
export const SHOT_SIZE_SPAN: Record<ShotSize, { bottom: number; top: number }> = {
wide: { bottom: -0.35, top: 1.25 },
full: { bottom: -0.05, top: 1.1 },
medium: { bottom: 0.45, top: 1.15 },
mcu: { bottom: 0.65, top: 1.15 },
closeup: { bottom: 0.78, top: 1.12 },
};
export const SHOT_SIZE_SPAN: Record<ShotSize, { bottom: number; top: number }> =
{
wide: { bottom: -0.35, top: 1.25 },
full: { bottom: -0.05, top: 1.1 },
medium: { bottom: 0.45, top: 1.15 },
mcu: { bottom: 0.65, top: 1.15 },
closeup: { bottom: 0.78, top: 1.12 },
};
/** Fraction of the frame's height the framed span should fill, leaving headroom margin. */
const FRAME_FILL = 0.92;
@@ -104,7 +113,8 @@ export const ANGLE_AZIMUTH_DEG: Record<CameraAngle, number> = {
* viewport (no exception, just a black frame). Fall back to a valid framing.
*/
export function angleAzimuthDeg(angle: string | undefined | null): number {
if (angle && angle in ANGLE_AZIMUTH_DEG) return ANGLE_AZIMUTH_DEG[angle as CameraAngle];
if (angle && angle in ANGLE_AZIMUTH_DEG)
return ANGLE_AZIMUTH_DEG[angle as CameraAngle];
return ANGLE_AZIMUTH_DEG.front;
}
@@ -117,7 +127,9 @@ export const ANGLE_ALIASES: Record<string, CameraAngle> = {
"34_right": "threeQuarterRight",
};
export function normalizeCameraAngle(angle: string | undefined | null): CameraAngle {
export function normalizeCameraAngle(
angle: string | undefined | null,
): CameraAngle {
if (angle && angle in ANGLE_AZIMUTH_DEG) return angle as CameraAngle;
if (angle && angle in ANGLE_ALIASES) return ANGLE_ALIASES[angle];
return "front";
@@ -146,13 +158,18 @@ export const ELEVATION_RATIO: Record<Elevation, number> = {
/** ELEVATION_RATIO lookup that falls back to eye level for unknown ids. */
export function elevationRatio(elevation: string | undefined | null): number {
if (elevation && elevation in ELEVATION_RATIO) return ELEVATION_RATIO[elevation as Elevation];
if (elevation && elevation in ELEVATION_RATIO)
return ELEVATION_RATIO[elevation as Elevation];
return ELEVATION_RATIO.eye;
}
/** SHOT_SIZE_SPAN lookup that falls back to a full shot for unknown ids. */
export function shotSizeSpan(shotSize: string | undefined | null): { bottom: number; top: number } {
if (shotSize && shotSize in SHOT_SIZE_SPAN) return SHOT_SIZE_SPAN[shotSize as ShotSize];
export function shotSizeSpan(shotSize: string | undefined | null): {
bottom: number;
top: number;
} {
if (shotSize && shotSize in SHOT_SIZE_SPAN)
return SHOT_SIZE_SPAN[shotSize as ShotSize];
return SHOT_SIZE_SPAN.full;
}
@@ -163,13 +180,20 @@ export function shotSizeSpan(shotSize: string | undefined | null): { bottom: num
* the SceneData JSON shape (composition is the string preset id).
*/
export function normalizeShotParams(
params: (Partial<Omit<ShotParams, "composition">> & { composition?: string }) | undefined | null,
params:
| (Partial<Omit<ShotParams, "composition">> & { composition?: string })
| undefined
| null,
): Partial<Omit<ShotParams, "composition">> & { composition?: string } {
if (!params || typeof params !== "object") return {};
const out: Partial<Omit<ShotParams, "composition">> & { composition?: string } = {};
if (params.shotSize && params.shotSize in SHOT_SIZE_SPAN) out.shotSize = params.shotSize;
const out: Partial<Omit<ShotParams, "composition">> & {
composition?: string;
} = {};
if (params.shotSize && params.shotSize in SHOT_SIZE_SPAN)
out.shotSize = params.shotSize;
if (params.angle) out.angle = normalizeCameraAngle(params.angle);
if (params.elevation && params.elevation in ELEVATION_RATIO) out.elevation = params.elevation;
if (params.elevation && params.elevation in ELEVATION_RATIO)
out.elevation = params.elevation;
if (params.composition) out.composition = params.composition;
return out;
}
@@ -188,11 +212,21 @@ export interface SolvedShot {
}
export function solveShot(input: SolveShotInput): SolvedShot {
const { anchors, targetAnchors, angle, elevation, composition, fovDeg, aspect } = input;
const {
anchors,
targetAnchors,
angle,
elevation,
composition,
fovDeg,
aspect,
} = input;
// Unknown size ids would index SHOT_SIZE_SPAN to undefined and NaN every
// derived frame value — clamp to a full shot instead.
const shotSize =
input.shotSize && input.shotSize in SHOT_SIZE_SPAN ? input.shotSize : "full";
input.shotSize && input.shotSize in SHOT_SIZE_SPAN
? input.shotSize
: "full";
const span = SHOT_SIZE_SPAN[shotSize];
const frameBottom = anchors.feet + span.bottom * anchors.height;
@@ -214,12 +248,14 @@ export function solveShot(input: SolveShotInput): SolvedShot {
// "ots" measures its azimuth relative to the character's *current* facing
// (anchors.facingYaw), so the camera stays behind their shoulder as they
// turn; every other angle keeps the original fixed world-space azimuth.
const azimuthRad = THREE.MathUtils.degToRad(angleAzimuthDeg(angle)) + (isOTS ? anchors.facingYaw : 0);
const azimuthRad =
THREE.MathUtils.degToRad(angleAzimuthDeg(angle)) +
(isOTS ? anchors.facingYaw : 0);
const cameraY = anchors.feet + elevationRatio(elevation) * anchors.height;
const position = new THREE.Vector3(
anchors.centerX + Math.sin(azimuthRad) * distance,
cameraY,
anchors.centerZ + Math.cos(azimuthRad) * distance
anchors.centerZ + Math.cos(azimuthRad) * distance,
);
// Every other angle frames the primary character itself. "ots" instead
@@ -229,16 +265,22 @@ export function solveShot(input: SolveShotInput): SolvedShot {
// isn't one.
const subjectPoint = isOTS
? targetAnchors
? new THREE.Vector3(targetAnchors.centerX, targetAnchors.chest, targetAnchors.centerZ)
? new THREE.Vector3(
targetAnchors.centerX,
targetAnchors.chest,
targetAnchors.centerZ,
)
: new THREE.Vector3(
anchors.centerX + Math.sin(anchors.facingYaw) * anchors.height,
frameCenterY,
anchors.centerZ + Math.cos(anchors.facingYaw) * anchors.height
anchors.centerZ + Math.cos(anchors.facingYaw) * anchors.height,
)
: new THREE.Vector3(anchors.centerX, frameCenterY, anchors.centerZ);
const forward = subjectPoint.clone().sub(position).normalize();
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
const right = new THREE.Vector3()
.crossVectors(forward, new THREE.Vector3(0, 1, 0))
.normalize();
const up = new THREE.Vector3().crossVectors(right, forward).normalize();
const hFovRad = 2 * Math.atan(Math.tan(vFovRad / 2) * aspect);
@@ -259,13 +301,41 @@ export function solveShot(input: SolveShotInput): SolvedShot {
return { position, target };
}
const CENTER_PRESET: CompositionPreset = { id: "center", label: "Center", screenX: 0.5, screenY: 0.5 };
const LEFT_PRESET: CompositionPreset = { id: "left", label: "Left", screenX: 1 / 3, screenY: 0.5 };
const RIGHT_PRESET: CompositionPreset = { id: "right", label: "Right", screenX: 2 / 3, screenY: 0.5 };
const UPPER_PRESET: CompositionPreset = { id: "upper", label: "Upper", screenX: 0.5, screenY: 2 / 3 };
const LOWER_PRESET: CompositionPreset = { id: "lower", label: "Lower", screenX: 0.5, screenY: 1 / 3 };
const CENTER_PRESET: CompositionPreset = {
id: "center",
label: "Center",
screenX: 0.5,
screenY: 0.5,
};
const LEFT_PRESET: CompositionPreset = {
id: "left",
label: "Left",
screenX: 1 / 3,
screenY: 0.5,
};
const RIGHT_PRESET: CompositionPreset = {
id: "right",
label: "Right",
screenX: 2 / 3,
screenY: 0.5,
};
const UPPER_PRESET: CompositionPreset = {
id: "upper",
label: "Upper",
screenX: 0.5,
screenY: 2 / 3,
};
const LOWER_PRESET: CompositionPreset = {
id: "lower",
label: "Lower",
screenX: 0.5,
screenY: 1 / 3,
};
function frameCenterYFor(shotSize: ShotSize, anchors: CharacterAnchors): number {
function frameCenterYFor(
shotSize: ShotSize,
anchors: CharacterAnchors,
): number {
const span = SHOT_SIZE_SPAN[shotSize];
return anchors.feet + ((span.bottom + span.top) / 2) * anchors.height;
}
@@ -297,92 +367,253 @@ export function assertShotSolverBasics(): void {
camera.updateMatrixWorld(true);
camera.updateProjectionMatrix();
const subjectY = frameCenterYFor(shotSize, anchors);
return new THREE.Vector3(anchors.centerX, subjectY, anchors.centerZ).project(camera);
return new THREE.Vector3(
anchors.centerX,
subjectY,
anchors.centerZ,
).project(camera);
};
const center = solveShot({ ...base, shotSize: "medium", angle: "front", elevation: "eye", composition: CENTER_PRESET });
const left = solveShot({ ...base, shotSize: "medium", angle: "front", elevation: "eye", composition: LEFT_PRESET });
const right = solveShot({ ...base, shotSize: "medium", angle: "front", elevation: "eye", composition: RIGHT_PRESET });
const upper = solveShot({ ...base, shotSize: "medium", angle: "front", elevation: "eye", composition: UPPER_PRESET });
const lower = solveShot({ ...base, shotSize: "medium", angle: "front", elevation: "eye", composition: LOWER_PRESET });
const center = solveShot({
...base,
shotSize: "medium",
angle: "front",
elevation: "eye",
composition: CENTER_PRESET,
});
const left = solveShot({
...base,
shotSize: "medium",
angle: "front",
elevation: "eye",
composition: LEFT_PRESET,
});
const right = solveShot({
...base,
shotSize: "medium",
angle: "front",
elevation: "eye",
composition: RIGHT_PRESET,
});
const upper = solveShot({
...base,
shotSize: "medium",
angle: "front",
elevation: "eye",
composition: UPPER_PRESET,
});
const lower = solveShot({
...base,
shotSize: "medium",
angle: "front",
elevation: "eye",
composition: LOWER_PRESET,
});
console.assert(Math.abs(projectSubject(center, "medium").x) < 0.05, "[shotSolver] center composition should sit at screen-center");
console.assert(projectSubject(left, "medium").x < -0.1, "[shotSolver] left composition should push the subject left");
console.assert(projectSubject(right, "medium").x > 0.1, "[shotSolver] right composition should push the subject right");
console.assert(projectSubject(upper, "medium").y > 0.1, "[shotSolver] upper composition should push the subject up");
console.assert(projectSubject(lower, "medium").y < -0.1, "[shotSolver] lower composition should push the subject down");
const closeup = solveShot({ ...base, shotSize: "closeup", angle: "front", elevation: "eye", composition: CENTER_PRESET });
const wide = solveShot({ ...base, shotSize: "wide", angle: "front", elevation: "eye", composition: CENTER_PRESET });
console.assert(
closeup.position.distanceTo(closeup.target) < wide.position.distanceTo(wide.target),
"[shotSolver] a close-up should sit closer to the subject than a wide shot"
Math.abs(projectSubject(center, "medium").x) < 0.05,
"[shotSolver] center composition should sit at screen-center",
);
console.assert(
projectSubject(left, "medium").x < -0.1,
"[shotSolver] left composition should push the subject left",
);
console.assert(
projectSubject(right, "medium").x > 0.1,
"[shotSolver] right composition should push the subject right",
);
console.assert(
projectSubject(upper, "medium").y > 0.1,
"[shotSolver] upper composition should push the subject up",
);
console.assert(
projectSubject(lower, "medium").y < -0.1,
"[shotSolver] lower composition should push the subject down",
);
const front = solveShot({ ...base, shotSize: "medium", angle: "front", elevation: "eye", composition: CENTER_PRESET });
const back = solveShot({ ...base, shotSize: "medium", angle: "back", elevation: "eye", composition: CENTER_PRESET });
const closeup = solveShot({
...base,
shotSize: "closeup",
angle: "front",
elevation: "eye",
composition: CENTER_PRESET,
});
const wide = solveShot({
...base,
shotSize: "wide",
angle: "front",
elevation: "eye",
composition: CENTER_PRESET,
});
console.assert(
closeup.position.distanceTo(closeup.target) <
wide.position.distanceTo(wide.target),
"[shotSolver] a close-up should sit closer to the subject than a wide shot",
);
const front = solveShot({
...base,
shotSize: "medium",
angle: "front",
elevation: "eye",
composition: CENTER_PRESET,
});
const back = solveShot({
...base,
shotSize: "medium",
angle: "back",
elevation: "eye",
composition: CENTER_PRESET,
});
console.assert(
front.position.z > 0 && back.position.z < 0,
"[shotSolver] front and back angles should place the camera on opposite sides"
"[shotSolver] front and back angles should place the camera on opposite sides",
);
const low = solveShot({ ...base, shotSize: "medium", angle: "front", elevation: "low", composition: CENTER_PRESET });
const high = solveShot({ ...base, shotSize: "medium", angle: "front", elevation: "high", composition: CENTER_PRESET });
console.assert(low.position.y < high.position.y, "[shotSolver] low elevation should sit below high elevation");
const low = solveShot({
...base,
shotSize: "medium",
angle: "front",
elevation: "low",
composition: CENTER_PRESET,
});
const high = solveShot({
...base,
shotSize: "medium",
angle: "front",
elevation: "high",
composition: CENTER_PRESET,
});
console.assert(
low.position.y < high.position.y,
"[shotSolver] low elevation should sit below high elevation",
);
// "ots" is an aim mode, not a subject-framing mode like every other angle —
// check it aims past the primary at the second character when one exists
// (instead of collapsing to a plain "back" shot of the primary), sits
// behind the primary like "back" does, and still looks generally ahead of
// the primary when there's no second character to shoot over the shoulder of.
const secondaryAnchors: CharacterAnchors = { ...anchors, centerZ: anchors.centerZ + 2 };
const otsWithSecondary = solveShot({ ...base, shotSize: "medium", angle: "ots", elevation: "eye", composition: CENTER_PRESET, targetAnchors: secondaryAnchors });
const secondaryAnchors: CharacterAnchors = {
...anchors,
centerZ: anchors.centerZ + 2,
};
const otsWithSecondary = solveShot({
...base,
shotSize: "medium",
angle: "ots",
elevation: "eye",
composition: CENTER_PRESET,
targetAnchors: secondaryAnchors,
});
console.assert(
otsWithSecondary.target.distanceTo(new THREE.Vector3(secondaryAnchors.centerX, secondaryAnchors.chest, secondaryAnchors.centerZ)) <
otsWithSecondary.target.distanceTo(new THREE.Vector3(anchors.centerX, anchors.chest, anchors.centerZ)),
"[shotSolver] ots with a second character should aim past the primary toward them"
otsWithSecondary.target.distanceTo(
new THREE.Vector3(
secondaryAnchors.centerX,
secondaryAnchors.chest,
secondaryAnchors.centerZ,
),
) <
otsWithSecondary.target.distanceTo(
new THREE.Vector3(anchors.centerX, anchors.chest, anchors.centerZ),
),
"[shotSolver] ots with a second character should aim past the primary toward them",
);
console.assert(otsWithSecondary.position.z < 0, "[shotSolver] ots camera should sit behind the primary character");
const otsSolo = solveShot({ ...base, shotSize: "medium", angle: "ots", elevation: "eye", composition: CENTER_PRESET });
console.assert(otsSolo.target.z > anchors.centerZ, "[shotSolver] ots with no second character should still look ahead of the primary");
console.assert(
otsSolo.position.distanceTo(new THREE.Vector3(anchors.centerX, otsSolo.position.y, anchors.centerZ)) < anchors.height,
"[shotSolver] ots camera should sit close to the primary's shoulder, not a full shot's distance away"
otsWithSecondary.position.z < 0,
"[shotSolver] ots camera should sit behind the primary character",
);
const otsSolo = solveShot({
...base,
shotSize: "medium",
angle: "ots",
elevation: "eye",
composition: CENTER_PRESET,
});
console.assert(
otsSolo.target.z > anchors.centerZ,
"[shotSolver] ots with no second character should still look ahead of the primary",
);
console.assert(
otsSolo.position.distanceTo(
new THREE.Vector3(anchors.centerX, otsSolo.position.y, anchors.centerZ),
) < anchors.height,
"[shotSolver] ots camera should sit close to the primary's shoulder, not a full shot's distance away",
);
// The camera must stay behind whichever way the character is actually
// facing, not a world-fixed direction — otherwise turning the character
// stops looking like an over-the-shoulder shot at all.
const turnedAnchors: CharacterAnchors = { ...anchors, facingYaw: Math.PI / 2 };
const otsTurned = solveShot({ ...base, anchors: turnedAnchors, shotSize: "medium", angle: "ots", elevation: "eye", composition: CENTER_PRESET });
const turnedAnchors: CharacterAnchors = {
...anchors,
facingYaw: Math.PI / 2,
};
const otsTurned = solveShot({
...base,
anchors: turnedAnchors,
shotSize: "medium",
angle: "ots",
elevation: "eye",
composition: CENTER_PRESET,
});
console.assert(
Math.abs(otsTurned.position.x - otsSolo.position.z) < 0.01 && Math.abs(otsTurned.position.z - (-otsSolo.position.x)) < 0.01,
"[shotSolver] ots camera placement should rotate along with the character's facing"
Math.abs(otsTurned.position.x - otsSolo.position.z) < 0.01 &&
Math.abs(otsTurned.position.z - -otsSolo.position.x) < 0.01,
"[shotSolver] ots camera placement should rotate along with the character's facing",
);
// Composition must land within tolerance across every shot size / angle
// combination the solver claims to support — not just the "medium, front"
// case the point checks above happen to use.
const sweepPresets = [CENTER_PRESET, LEFT_PRESET, RIGHT_PRESET, UPPER_PRESET, LOWER_PRESET];
const sweepShotSizes: ShotSize[] = ["wide", "full", "medium", "mcu", "closeup"];
const sweepAngles: CameraAngle[] = ["front", "threeQuarterLeft", "threeQuarterRight", "profile"];
const sweepPresets = [
CENTER_PRESET,
LEFT_PRESET,
RIGHT_PRESET,
UPPER_PRESET,
LOWER_PRESET,
];
const sweepShotSizes: ShotSize[] = [
"wide",
"full",
"medium",
"mcu",
"closeup",
];
const sweepAngles: CameraAngle[] = [
"front",
"threeQuarterLeft",
"threeQuarterRight",
"profile",
];
const TOLERANCE = 0.08;
let sweepFailures = 0;
for (const shotSize of sweepShotSizes) {
for (const angle of sweepAngles) {
for (const preset of sweepPresets) {
const shot = solveShot({ ...base, shotSize, angle, elevation: "eye", composition: preset });
const shot = solveShot({
...base,
shotSize,
angle,
elevation: "eye",
composition: preset,
});
const ndc = projectSubject(shot, shotSize);
const expectedX = preset.screenX * 2 - 1;
const expectedY = preset.screenY * 2 - 1;
if (Math.abs(ndc.x - expectedX) > TOLERANCE || Math.abs(ndc.y - expectedY) > TOLERANCE) {
if (
Math.abs(ndc.x - expectedX) > TOLERANCE ||
Math.abs(ndc.y - expectedY) > TOLERANCE
) {
sweepFailures++;
console.error(
`[shotSolver] composition drift: ${shotSize}/${angle}/${preset.id} expected NDC (${expectedX.toFixed(2)}, ${expectedY.toFixed(2)}) got (${ndc.x.toFixed(2)}, ${ndc.y.toFixed(2)})`
`[shotSolver] composition drift: ${shotSize}/${angle}/${preset.id} expected NDC (${expectedX.toFixed(2)}, ${expectedY.toFixed(2)}) got (${ndc.x.toFixed(2)}, ${ndc.y.toFixed(2)})`,
);
}
}
}
}
console.assert(sweepFailures === 0, `[shotSolver] ${sweepFailures} shot-size/angle/composition combinations drifted outside tolerance`);
console.assert(
sweepFailures === 0,
`[shotSolver] ${sweepFailures} shot-size/angle/composition combinations drifted outside tolerance`,
);
}

Some files were not shown because too many files have changed in this diff Show More