Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2bcbc10df | ||
|
|
2004f527cc | ||
|
|
dca88b3765 | ||
|
|
54880e900b | ||
|
|
4c849e9ef9 | ||
|
|
46fd5bbc86 | ||
|
|
fc6616b118 | ||
|
|
22f82fbed6 | ||
|
|
67eb822efe | ||
|
|
99574c2226 | ||
|
|
88965fa70d | ||
|
|
9d54b9d8dd | ||
|
|
a666f6bd12 | ||
|
|
b02c50fdf7 | ||
|
|
7e9a5be19d | ||
|
|
e49b8b0072 | ||
|
|
ddc02fbe4c | ||
|
|
d076978a4a | ||
|
|
4e200cc8ac | ||
|
|
bfcf542e59 | ||
|
|
3d1c4ca5af | ||
|
|
24129438e8 | ||
|
|
155d914df3 |
@@ -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 },
|
||||
},
|
||||
];
|
||||
Generated
+4511
-4256
File diff suppressed because it is too large
Load Diff
+39
-37
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export async function handle({ event, resolve }) {
|
||||
const response = await resolve(event);
|
||||
return response;
|
||||
const response = await resolve(event);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>    </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));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+2469
-2224
File diff suppressed because it is too large
Load Diff
+2425
-2225
File diff suppressed because it is too large
Load Diff
+2459
-2224
File diff suppressed because it is too large
Load Diff
+2476
-2224
File diff suppressed because it is too large
Load Diff
+2444
-2222
File diff suppressed because it is too large
Load Diff
+2416
-2222
File diff suppressed because it is too large
Load Diff
+2333
-2223
File diff suppressed because it is too large
Load Diff
+150
-144
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user