diff --git a/docs/plan-composite-params.md b/docs/plan-composite-params.md index 7f54986..c360fb0 100644 --- a/docs/plan-composite-params.md +++ b/docs/plan-composite-params.md @@ -52,12 +52,14 @@ Preview (`catalog.ts`, маршруты `preview/**`, `SchemaToolView`, `SchemaF импортируют из `$lib/registry-new`. После удаления старого UI `registry-new/` переименуется в `registry`. -**Нюанс по worker:** `executor.worker.ts` резолвит инструменты по `id` в -**старом** registry. Для инструментов, существующих в обоих (сейчас -`add-border/add-stroke`), worker работает. Для новых инструментов, которых нет в -старом registry, worker не найдёт → `executeStep` откатится на `runDirect` -(fallback в `catch`) — это корректно, но стоит поправить worker на новый registry -при дальнейшей миграции. +**Executor/worker для нового UI (осознанное дублирование):** общий +`executor.worker.ts` резолвит инструменты по `id` в **старом** registry — для +новых инструментов их там нет. Поэтому `executor.ts` + `executor.worker.ts` +**скопированы** в `web/src/lib/preview/` и переписаны под `registry-new` + +`sanitizeSchemaParams`. Старый executor/worker под старый UI остаются +нетронутыми; `SchemaToolView` использует `$lib/preview/executor`. Это +дублирование необходимо до ухода старого UI (после — preview-executor заменяет +общий). ## Проблема (текущая) diff --git a/web/src/lib/components/kit/ParamControl.svelte b/web/src/lib/components/kit/ParamControl.svelte deleted file mode 100644 index 9e5062b..0000000 --- a/web/src/lib/components/kit/ParamControl.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - -{#if param.type === "color"} - onInput(v)} - /> -{:else if param.type === "slider"} - onInput(v)} - /> -{:else if param.type === "select"} - onInput(v)} - /> -{:else if param.type === "checkbox"} - onInput(v)} - /> -{:else if param.type === "text"} - onInput(v)} - /> -{:else if param.type === "number"} - onInput(v)} - /> -{/if} diff --git a/web/src/lib/components/kit/SchemaToolView.svelte b/web/src/lib/components/kit/SchemaToolView.svelte index 7c1a097..4306a3b 100644 --- a/web/src/lib/components/kit/SchemaToolView.svelte +++ b/web/src/lib/components/kit/SchemaToolView.svelte @@ -1,16 +1,16 @@ - - -
- {#if steps.length === 0} - - {/if} - {#each steps as step, i (step.toolId + i)} - {@const st = getTool(step.toolId)} - onRemoveStep(i)} - > - {#if st} -
- {#each st.params as p (p.id)} - onSetParam(i, p.id, v)} - /> - {/each} -
- {/if} -
- {/each} -
- -
- onSetAddId(v)} - /> -
-
-
- - diff --git a/web/src/lib/components/kit/ToolView.svelte b/web/src/lib/components/kit/ToolView.svelte deleted file mode 100644 index b269708..0000000 --- a/web/src/lib/components/kit/ToolView.svelte +++ /dev/null @@ -1,326 +0,0 @@ - - -{#if view} -
-
PNG PROCESSING / SINGLE TOOL
-
-
-

{view.title}

-

{view.lede}

-
- LIVE PREVIEW -
- -
-
-
-
- {view.panelLabel} - {view.panelStrong} -
- {view.stepType} -
- - {#each view.fields as f} - {#if f.kind === "segmented"} - {@const sel = num(form[f.id])} -
- -
- {#each f.options as opt, i} - - {/each} -
-
- {:else if f.kind === "color-pair"} - {@const cp = pair(form[f.id])} -
- -
-
- - - (form[f.id] = { from: e.currentTarget.value, to: cp.to })} - /> -
- -
- - - (form[f.id] = { - from: cp.from, - to: e.currentTarget.value, - })} - /> -
-
-
-
- {:else if f.kind === "slider" || f.kind === "direction"} - {@const v = num(form[f.id])} -
- - (form[f.id] = Number(e.currentTarget.value))} - /> - {#if f.kind === "direction"} -
- {#each DIRS as a, i} - - {/each} -
- {:else if f.hints} -
- {f.hints[0]} - {f.hints[1]} -
- {/if} -
- {:else if f.kind === "color"} - {@const v = str(form[f.id])} -
- -
- - (form[f.id] = e.currentTarget.value)} - /> - {#if f.native} - (form[f.id] = e.currentTarget.value)} - /> - {/if} -
- {#if f.reference} -
- sampled from image background -
- {/if} -
- {:else if f.kind === "toggle"} - {@const on = bool(form[f.id])} -
- -

{f.note}

-
- {/if} - {/each} - - -
- - {#if view.preview === "gradient"} -
-
-
- {view.toolbarLabel} - {view.fileName} -
-
- - -
-
-
-
-
PNG
- easy-png-tools -
-
-
-
- GENERATED CSS - -
-
{grad?.code}
-
-
- {:else} -
-
-
- {view.toolbarLabel} - {view.fileName} -
-
- processed - -
-
-
-
-
- SOURCEoriginal.png -
-
-
- OBJECT -
- 1200 × 800 -
-
-
-
- RESULTremoved-bg.png -
-
-
- PNG -
- 1200 × 800 -
-
-
-
- FORMAT PNG-24 - ALPHA ENABLED - SIMILARITY 72 % -
-
- {/if} -
-
-{:else} -
-
PNG PROCESSING / SINGLE TOOL
-
-
-

{tool.title}

-

{tool.description}

-
- LIVE PREVIEW -
-
-
-
-
- {tool.category.toUpperCase()} SETTINGS - Configure output -
- TOOL 01 -
- -
-
-
-{/if} diff --git a/web/src/lib/preview/executor/executor.ts b/web/src/lib/preview/executor/executor.ts new file mode 100644 index 0000000..fd54cce --- /dev/null +++ b/web/src/lib/preview/executor/executor.ts @@ -0,0 +1,136 @@ +import { ToolError } from "$lib/core/errors"; +import type { PixelImage } from "$lib/core/types"; + +type MaybeRunnable = { + id: string; + domOnly?: boolean; + run?: ( + img: PixelImage, + params: Record, + ) => Promise | PixelImage; +}; + +export async function executeStep( + tool: MaybeRunnable, + img: PixelImage, + params: Record, +): Promise { + if (!tool.run) { + throw new Error("errors.noImageRun"); + } + if (tool.domOnly) { + return await runDirect(tool, img, params); + } + if (typeof Worker === "undefined") { + return await runDirect(tool, img, params); + } + const worker = ensureWorker(); + if (worker === null) { + return await runDirect(tool, img, params); + } + try { + return await runInWorker(worker, tool.id, img, params); + } catch (workerError) { + disableWorker(); + void workerError; + return await runDirect(tool, img, params); + } +} + +async function runDirect( + tool: MaybeRunnable, + img: PixelImage, + params: Record, +): Promise { + if (!tool.run) { + throw new Error("errors.noImageRun"); + } + return await tool.run(img, params); +} + +let worker: Worker | null = null; +let workerTried = false; +let nextRequestId = 1; +const pending = new Map< + number, + { resolve: (img: PixelImage) => void; reject: (e: Error) => void } +>(); + +function ensureWorker(): Worker | null { + if (workerTried) return worker; + workerTried = true; + try { + const candidate = new Worker( + new URL("./executor.worker.ts", import.meta.url), + { + type: "module", + }, + ); + candidate.onmessage = (event: MessageEvent) => { + const payload = event.data as { + id: number; + ok: boolean; + width?: number; + height?: number; + data?: Uint8ClampedArray; + error?: string; + errorKey?: string; + errorVars?: Record; + }; + const entry = pending.get(payload.id); + if (!entry) return; + pending.delete(payload.id); + if (payload.ok && payload.data && payload.width && payload.height) { + entry.resolve({ + width: payload.width, + height: payload.height, + data: new Uint8ClampedArray(payload.data), + }); + } else if (payload.errorKey) { + entry.reject(new ToolError(payload.errorKey, payload.errorVars)); + } else { + entry.reject(new Error(payload.error ?? "errors.workerFailed")); + } + }; + candidate.onerror = () => { + worker = null; + for (const entry of pending.values()) { + entry.reject(new Error("errors.workerUnavailable")); + } + pending.clear(); + }; + worker = candidate; + } catch { + worker = null; + } + return worker; +} + +function disableWorker(): void { + if (worker) { + worker.terminate(); + } + worker = null; +} + +function runInWorker( + workerInstance: Worker, + toolId: string, + img: PixelImage, + params: Record, +): Promise { + return new Promise((resolve, reject) => { + const id = nextRequestId++; + pending.set(id, { resolve, reject }); + workerInstance.postMessage({ + id, + toolId, + image: { + width: img.width, + height: img.height, + data: new Uint8ClampedArray(img.data), + }, + params, + }); + }); +} diff --git a/web/src/lib/preview/executor/executor.worker.ts b/web/src/lib/preview/executor/executor.worker.ts new file mode 100644 index 0000000..e67597e --- /dev/null +++ b/web/src/lib/preview/executor/executor.worker.ts @@ -0,0 +1,51 @@ +/// +import { ToolError } from "$lib/core/errors"; +import type { PixelImage } from "$lib/core/types"; +import { getTool } from "$lib/registry-new"; +import { sanitizeSchemaParams } from "$lib/registry-schema"; + +type WorkerRequest = { + id: number; + toolId: string; + image: { width: number; height: number; data: Uint8ClampedArray }; + params: Record; +}; + +self.onmessage = (event: MessageEvent) => { + void handle(event.data); +}; + +async function handle(request: WorkerRequest): Promise { + try { + const tool = getTool(request.toolId); + if (!tool?.run) { + throw new Error("errors.noImageRun"); + } + const image: PixelImage = { + width: request.image.width, + height: request.image.height, + data: new Uint8ClampedArray(request.image.data), + }; + const output = await tool.run( + image, + sanitizeSchemaParams(tool.schema, request.params), + ); + const payload = { + id: request.id, + ok: true, + width: output.width, + height: output.height, + data: output.data, + }; + (self as unknown as Worker).postMessage(payload, [output.data.buffer]); + } catch (e) { + const toolError = e instanceof ToolError ? e : undefined; + (self as unknown as Worker).postMessage({ + id: request.id, + ok: false, + error: e instanceof Error ? e.message : String(e), + errorKey: toolError?.key, + errorVars: toolError?.vars, + }); + } +} diff --git a/web/src/lib/preview/executor/index.ts b/web/src/lib/preview/executor/index.ts new file mode 100644 index 0000000..e4220e2 --- /dev/null +++ b/web/src/lib/preview/executor/index.ts @@ -0,0 +1 @@ +export { executeStep } from "./executor"; diff --git a/web/src/lib/preview/tool-views.ts b/web/src/lib/preview/tool-views.ts deleted file mode 100644 index 75fed12..0000000 --- a/web/src/lib/preview/tool-views.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { ToolEntry } from "$lib/registry"; - -export type SegmentedField = { - id: string; - kind: "segmented"; - label: string; - options: string[]; - selected: number; -}; -export type ColorPairField = { - id: string; - kind: "color-pair"; - label: string; - from: string; - to: string; -}; -export type SliderField = { - id: string; - kind: "slider" | "direction"; - label: string; - value: number; - min: number; - max: number; - suffix: string; - space?: boolean; - hints?: [string, string]; -}; -export type ColorField = { - id: string; - kind: "color"; - label: string; - value: string; - native?: boolean; - reference?: boolean; -}; -export type ToggleField = { - id: string; - kind: "toggle"; - label: string; - checked: boolean; - note: string; -}; - -export type FieldDef = - SegmentedField | ColorPairField | SliderField | ColorField | ToggleField; - -export interface PreviewToolView { - title: string; - lede: string; - panelLabel: string; - panelStrong: string; - stepType: string; - toolbarLabel: string; - fileName: string; - downloadLabel: string; - layoutClass: string; - previewClass: string; - preview: "gradient" | "comparison"; - fields: FieldDef[]; -} - -export type FieldValue = - number | string | boolean | { from: string; to: string }; - -function initValue(f: FieldDef): FieldValue { - switch (f.kind) { - case "segmented": - return f.selected; - case "color-pair": - return { from: f.from, to: f.to }; - case "color": - return f.value; - case "slider": - case "direction": - return f.value; - case "toggle": - return f.checked; - } -} - -export function initForm(view: PreviewToolView): Record { - const form: Record = {}; - for (const f of view.fields) form[f.id] = initValue(f); - return form; -} - -const gradient: PreviewToolView = { - title: "Gradient background.", - lede: "Create a clean, export-ready gradient with precise control over color, direction and transparency.", - panelLabel: "GRADIENT SETTINGS", - panelStrong: "Configure output", - stepType: "TOOL 01", - toolbarLabel: "OUTPUT PREVIEW", - fileName: "gradient.png", - downloadLabel: "Download PNG", - layoutClass: "gradient-layout", - previewClass: "gradient-preview", - preview: "gradient", - fields: [ - { - id: "type", - kind: "segmented", - label: "GRADIENT TYPE", - options: ["Linear", "Radial"], - selected: 0, - }, - { - id: "stops", - kind: "color-pair", - label: "COLOR STOPS", - from: "#1769D2", - to: "#00A8C7", - }, - { - id: "dir", - kind: "direction", - label: "DIRECTION", - value: 135, - min: 0, - max: 360, - suffix: "°", - space: true, - }, - { - id: "op", - kind: "slider", - label: "OPACITY", - value: 100, - min: 0, - max: 100, - suffix: "%", - space: true, - }, - ], -}; - -const background: PreviewToolView = { - title: "Remove background.", - lede: "Select a background color and tune the edge detection. Changes are processed automatically in your browser.", - panelLabel: "REMOVER SETTINGS", - panelStrong: "Configure detection", - stepType: "TOOL 02", - toolbarLabel: "SOURCE / RESULT", - fileName: "comparison.png", - downloadLabel: "Download result", - layoutClass: "remover-layout", - previewClass: "remover-preview", - preview: "comparison", - fields: [ - { - id: "color", - kind: "color", - label: "BACKGROUND COLOR", - value: "#E8EEF2", - native: true, - reference: true, - }, - { - id: "sim", - kind: "slider", - label: "COLOR SIMILARITY", - value: 72, - min: 0, - max: 100, - suffix: "%", - space: true, - hints: ["strict edges", "more removal"], - }, - { - id: "outer", - kind: "toggle", - label: "OUTER COLOR ONLY", - checked: true, - note: "Only remove connected background pixels from the edges.", - }, - { - id: "mask", - kind: "toggle", - label: "SHOW MASK", - checked: false, - note: "Preview the detected transparency mask.", - }, - ], -}; - -export const PREVIEW_TOOL_VIEWS: Record = { - "linear-gradient-png": gradient, - "remove-background-png": background, -}; - -export function getPreviewView(tool: ToolEntry): PreviewToolView | undefined { - return PREVIEW_TOOL_VIEWS[tool.id]; -}