mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: double executor to use with preview
This commit is contained in:
@@ -52,12 +52,14 @@ Preview (`catalog.ts`, маршруты `preview/**`, `SchemaToolView`, `SchemaF
|
|||||||
импортируют из `$lib/registry-new`. После удаления старого UI `registry-new/`
|
импортируют из `$lib/registry-new`. После удаления старого UI `registry-new/`
|
||||||
переименуется в `registry`.
|
переименуется в `registry`.
|
||||||
|
|
||||||
**Нюанс по worker:** `executor.worker.ts` резолвит инструменты по `id` в
|
**Executor/worker для нового UI (осознанное дублирование):** общий
|
||||||
**старом** registry. Для инструментов, существующих в обоих (сейчас
|
`executor.worker.ts` резолвит инструменты по `id` в **старом** registry — для
|
||||||
`add-border/add-stroke`), worker работает. Для новых инструментов, которых нет в
|
новых инструментов их там нет. Поэтому `executor.ts` + `executor.worker.ts`
|
||||||
старом registry, worker не найдёт → `executeStep` откатится на `runDirect`
|
**скопированы** в `web/src/lib/preview/` и переписаны под `registry-new` +
|
||||||
(fallback в `catch`) — это корректно, но стоит поправить worker на новый registry
|
`sanitizeSchemaParams`. Старый executor/worker под старый UI остаются
|
||||||
при дальнейшей миграции.
|
нетронутыми; `SchemaToolView` использует `$lib/preview/executor`. Это
|
||||||
|
дублирование необходимо до ухода старого UI (после — preview-executor заменяет
|
||||||
|
общий).
|
||||||
|
|
||||||
## Проблема (текущая)
|
## Проблема (текущая)
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ColorField from "$lib/components/kit/fields/ColorField.svelte";
|
|
||||||
import NumberField from "$lib/components/kit/fields/NumberField.svelte";
|
|
||||||
import SelectField from "$lib/components/kit/fields/SelectField.svelte";
|
|
||||||
import SliderField from "$lib/components/kit/fields/SliderField.svelte";
|
|
||||||
import TextField from "$lib/components/kit/fields/TextField.svelte";
|
|
||||||
import ToggleRow from "$lib/components/kit/fields/ToggleRow.svelte";
|
|
||||||
import type { ParamDef } from "$lib/registry";
|
|
||||||
|
|
||||||
type ParamValue = string | number | boolean;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
param: ParamDef;
|
|
||||||
value: ParamValue;
|
|
||||||
onInput: (value: ParamValue) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { param, value, onInput }: Props = $props();
|
|
||||||
|
|
||||||
// TODO: investigate - may be better move param fields switch to field component?
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if param.type === "color"}
|
|
||||||
<ColorField
|
|
||||||
label={param.label}
|
|
||||||
value={value as string}
|
|
||||||
oninput={(v) => onInput(v)}
|
|
||||||
/>
|
|
||||||
{:else if param.type === "slider"}
|
|
||||||
<SliderField
|
|
||||||
label={param.label}
|
|
||||||
value={value as number}
|
|
||||||
min={param.min}
|
|
||||||
max={param.max}
|
|
||||||
step={param.step}
|
|
||||||
oninput={(v) => onInput(v)}
|
|
||||||
/>
|
|
||||||
{:else if param.type === "select"}
|
|
||||||
<SelectField
|
|
||||||
label={param.label}
|
|
||||||
value={value as string}
|
|
||||||
options={param.options}
|
|
||||||
onchange={(v) => onInput(v)}
|
|
||||||
/>
|
|
||||||
{:else if param.type === "checkbox"}
|
|
||||||
<ToggleRow
|
|
||||||
label={param.label}
|
|
||||||
checked={value as boolean}
|
|
||||||
onchange={(v) => onInput(v)}
|
|
||||||
/>
|
|
||||||
{:else if param.type === "text"}
|
|
||||||
<TextField
|
|
||||||
label={param.label}
|
|
||||||
value={value as string}
|
|
||||||
placeholder={param.placeholder}
|
|
||||||
oninput={(v) => onInput(v)}
|
|
||||||
/>
|
|
||||||
{:else if param.type === "number"}
|
|
||||||
<NumberField
|
|
||||||
label={param.label}
|
|
||||||
value={value as number}
|
|
||||||
min={param.min}
|
|
||||||
max={param.max}
|
|
||||||
step={param.step}
|
|
||||||
oninput={(v) => onInput(v)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { decodeFile, encode } from "$lib/core/io";
|
import SchemaFields from "$lib/components/kit/SchemaFields.svelte";
|
||||||
|
import SchemaPreview from "$lib/components/kit/SchemaPreview.svelte";
|
||||||
import { debounce } from "$lib/core/debounce";
|
import { debounce } from "$lib/core/debounce";
|
||||||
|
import { decodeFile, encode } from "$lib/core/io";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
|
import { executeStep } from "$lib/preview/executor";
|
||||||
|
import type { ToolEntry } from "$lib/registry-new";
|
||||||
import {
|
import {
|
||||||
defaultSchemaParams,
|
defaultSchemaParams,
|
||||||
sanitizeSchemaParams,
|
sanitizeSchemaParams,
|
||||||
type ToolSchema,
|
type ToolSchema,
|
||||||
} from "$lib/registry-schema";
|
} from "$lib/registry-schema";
|
||||||
import { executeStep } from "$lib/tools/executor";
|
|
||||||
import type { ToolEntry } from "$lib/registry-new";
|
|
||||||
import SchemaFields from "$lib/components/kit/SchemaFields.svelte";
|
|
||||||
import SchemaPreview from "$lib/components/kit/SchemaPreview.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tool: ToolEntry;
|
tool: ToolEntry;
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import EmptyState from "$lib/components/kit/EmptyState.svelte";
|
|
||||||
import SelectField from "$lib/components/kit/fields/SelectField.svelte";
|
|
||||||
import Panel from "$lib/components/kit/layout/Panel.svelte";
|
|
||||||
import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte";
|
|
||||||
import StepCard from "$lib/components/kit/StepCard.svelte";
|
|
||||||
import Button from "$lib/components/kit/ui/Button.svelte";
|
|
||||||
import { getTool } from "$lib/registry";
|
|
||||||
import ParamControl from "./ParamControl.svelte";
|
|
||||||
|
|
||||||
type ParamValue = string | number | boolean;
|
|
||||||
|
|
||||||
interface ChainStep {
|
|
||||||
toolId: string;
|
|
||||||
params: Record<string, ParamValue>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
steps: ChainStep[];
|
|
||||||
chainOptions: { value: string; label: string }[];
|
|
||||||
addId: string;
|
|
||||||
onAddStep: () => void;
|
|
||||||
onSetAddId: (value: string) => void;
|
|
||||||
onRemoveStep: (index: number) => void;
|
|
||||||
onSetParam: (index: number, id: string, value: ParamValue) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
steps,
|
|
||||||
chainOptions,
|
|
||||||
addId,
|
|
||||||
onAddStep,
|
|
||||||
onSetAddId,
|
|
||||||
onRemoveStep,
|
|
||||||
onSetParam,
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Panel title="Pipeline" eyebrow="STEPS">
|
|
||||||
<div class="pipeline-body">
|
|
||||||
{#if steps.length === 0}
|
|
||||||
<EmptyState title="Pipeline empty" description="Add a tool step below." />
|
|
||||||
{/if}
|
|
||||||
{#each steps as step, i (step.toolId + i)}
|
|
||||||
{@const st = getTool(step.toolId)}
|
|
||||||
<StepCard
|
|
||||||
index={i + 1}
|
|
||||||
title={st?.title ?? step.toolId}
|
|
||||||
type={st?.category}
|
|
||||||
onremove={() => onRemoveStep(i)}
|
|
||||||
>
|
|
||||||
{#if st}
|
|
||||||
<div class="step-controls">
|
|
||||||
{#each st.params as p (p.id)}
|
|
||||||
<ParamControl
|
|
||||||
param={p}
|
|
||||||
value={step.params[p.id]}
|
|
||||||
onInput={(v) => onSetParam(i, p.id, v)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</StepCard>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<SettingsFooter>
|
|
||||||
<div class="add-step">
|
|
||||||
<SelectField
|
|
||||||
label="Add step"
|
|
||||||
value={addId}
|
|
||||||
options={chainOptions}
|
|
||||||
onchange={(v) => onSetAddId(v)}
|
|
||||||
/>
|
|
||||||
<Button variant="outline" onclick={onAddStep} label="Add step" />
|
|
||||||
</div>
|
|
||||||
</SettingsFooter>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.pipeline-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
.step-controls {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
.add-step {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 0.75rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.add-step :global(.select-field),
|
|
||||||
.add-step :global(select) {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolEntry } from "$lib/registry";
|
|
||||||
import {
|
|
||||||
getPreviewView,
|
|
||||||
initForm,
|
|
||||||
type FieldValue,
|
|
||||||
type PreviewToolView,
|
|
||||||
type SegmentedField,
|
|
||||||
} from "$lib/preview/tool-views";
|
|
||||||
import {
|
|
||||||
Copy,
|
|
||||||
Download,
|
|
||||||
Check,
|
|
||||||
RotateCcw,
|
|
||||||
ChevronDown,
|
|
||||||
} from "@lucide/svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tool: ToolEntry;
|
|
||||||
}
|
|
||||||
let { tool }: Props = $props();
|
|
||||||
|
|
||||||
const view = $derived(getPreviewView(tool));
|
|
||||||
let form = $state<Record<string, FieldValue>>(view ? initForm(view) : {});
|
|
||||||
$effect(() => {
|
|
||||||
if (view) form = initForm(view);
|
|
||||||
});
|
|
||||||
function reset() {
|
|
||||||
if (view) form = initForm(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = (v: FieldValue) => v as number;
|
|
||||||
const str = (v: FieldValue) => v as string;
|
|
||||||
const bool = (v: FieldValue) => v as boolean;
|
|
||||||
const pair = (v: FieldValue) => v as { from: string; to: string };
|
|
||||||
|
|
||||||
const grad = $derived.by(() => {
|
|
||||||
if (!view || view.preview !== "gradient") return null;
|
|
||||||
const typeSel = num(form["type"]);
|
|
||||||
const type = (view.fields.find((f) => f.id === "type") as SegmentedField)
|
|
||||||
.options[typeSel];
|
|
||||||
const cp = pair(form["stops"]);
|
|
||||||
const dir = num(form["dir"]);
|
|
||||||
const op = num(form["op"]);
|
|
||||||
const css =
|
|
||||||
type === "Radial"
|
|
||||||
? `radial-gradient(circle, ${cp.from} 0%, ${cp.to} 100%)`
|
|
||||||
: `linear-gradient(${dir}deg, ${cp.from} 0%, ${cp.to} 100%)`;
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
cp,
|
|
||||||
dir,
|
|
||||||
op,
|
|
||||||
css,
|
|
||||||
code: `background: ${css};\nopacity: ${op / 100};`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const DIRS = [0, 45, 90, 135, 180, 225, 270, 315];
|
|
||||||
const DIR_GLYPHS = ["→", "↗", "↑", "↖", "←", "↙", "↓", "↘"];
|
|
||||||
const SUBJECT_BG =
|
|
||||||
"linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)";
|
|
||||||
const SUBJECT_CLIP =
|
|
||||||
"polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)";
|
|
||||||
const CHECKER =
|
|
||||||
"repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if view}
|
|
||||||
<div class="tool-page">
|
|
||||||
<div class="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div>
|
|
||||||
<div class="tool-title">
|
|
||||||
<div>
|
|
||||||
<h1>{view.title}</h1>
|
|
||||||
<p class="lede">{view.lede}</p>
|
|
||||||
</div>
|
|
||||||
<span class="tool-status"><i></i> LIVE PREVIEW</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={view.layoutClass}>
|
|
||||||
<section class="settings-panel">
|
|
||||||
<div class="panel-heading">
|
|
||||||
<div>
|
|
||||||
<span class="label">{view.panelLabel}</span>
|
|
||||||
<strong>{view.panelStrong}</strong>
|
|
||||||
</div>
|
|
||||||
<span class="step-type">{view.stepType}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each view.fields as f}
|
|
||||||
{#if f.kind === "segmented"}
|
|
||||||
{@const sel = num(form[f.id])}
|
|
||||||
<div class="setting-group">
|
|
||||||
<label>{f.label}</label>
|
|
||||||
<div class="segmented wide">
|
|
||||||
{#each f.options as opt, i}
|
|
||||||
<button
|
|
||||||
class={i === sel ? "selected" : ""}
|
|
||||||
onclick={() => (form[f.id] = i)}
|
|
||||||
>
|
|
||||||
{opt}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if f.kind === "color-pair"}
|
|
||||||
{@const cp = pair(form[f.id])}
|
|
||||||
<div class="setting-group">
|
|
||||||
<label>{f.label}</label>
|
|
||||||
<div class="color-row">
|
|
||||||
<div class="color-field">
|
|
||||||
<span class="swatch" style="background:{cp.from}"></span>
|
|
||||||
<input
|
|
||||||
value={cp.from}
|
|
||||||
oninput={(e) =>
|
|
||||||
(form[f.id] = { from: e.currentTarget.value, to: cp.to })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="stop-arrow">→</span>
|
|
||||||
<div class="color-field">
|
|
||||||
<span class="swatch" style="background:{cp.to}"></span>
|
|
||||||
<input
|
|
||||||
value={cp.to}
|
|
||||||
oninput={(e) =>
|
|
||||||
(form[f.id] = {
|
|
||||||
from: cp.from,
|
|
||||||
to: e.currentTarget.value,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="gradient-bar" style="background:{grad?.css}"></div>
|
|
||||||
</div>
|
|
||||||
{:else if f.kind === "slider" || f.kind === "direction"}
|
|
||||||
{@const v = num(form[f.id])}
|
|
||||||
<div class="setting-group">
|
|
||||||
<label
|
|
||||||
>{f.label}
|
|
||||||
<output>{v}{f.space ? " " : ""}{f.suffix}</output></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={f.min}
|
|
||||||
max={f.max}
|
|
||||||
value={v}
|
|
||||||
oninput={(e) => (form[f.id] = Number(e.currentTarget.value))}
|
|
||||||
/>
|
|
||||||
{#if f.kind === "direction"}
|
|
||||||
<div class="direction-grid">
|
|
||||||
{#each DIRS as a, i}
|
|
||||||
<button
|
|
||||||
class={a === v ? "selected" : ""}
|
|
||||||
onclick={() => (form[f.id] = a)}
|
|
||||||
>
|
|
||||||
{a}° {DIR_GLYPHS[i]}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if f.hints}
|
|
||||||
<div class="range-hints">
|
|
||||||
<span>{f.hints[0]}</span>
|
|
||||||
<span>{f.hints[1]}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if f.kind === "color"}
|
|
||||||
{@const v = str(form[f.id])}
|
|
||||||
<div class="setting-group">
|
|
||||||
<label>{f.label}</label>
|
|
||||||
<div class="color-field">
|
|
||||||
<span class="swatch" style="background:{v}"></span>
|
|
||||||
<input
|
|
||||||
value={v}
|
|
||||||
oninput={(e) => (form[f.id] = e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
{#if f.native}
|
|
||||||
<input
|
|
||||||
class="native-color"
|
|
||||||
type="color"
|
|
||||||
aria-label="Choose background color"
|
|
||||||
value={v}
|
|
||||||
oninput={(e) => (form[f.id] = e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if f.reference}
|
|
||||||
<div class="color-reference">
|
|
||||||
<span style="background:{v}"></span> sampled from image background
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if f.kind === "toggle"}
|
|
||||||
{@const on = bool(form[f.id])}
|
|
||||||
<div class="setting-group toggle-group">
|
|
||||||
<label>
|
|
||||||
<span>{f.label}</span>
|
|
||||||
<b class="toggle" class:on onclick={() => (form[f.id] = !on)}
|
|
||||||
></b>
|
|
||||||
</label>
|
|
||||||
<p>{f.note}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<div class="settings-footer">
|
|
||||||
<button class="reset-btn" onclick={reset}>
|
|
||||||
<RotateCcw size={14} /> Reset
|
|
||||||
</button>
|
|
||||||
<span class="auto-note"><i></i> updates automatically</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if view.preview === "gradient"}
|
|
||||||
<section class={view.previewClass}>
|
|
||||||
<div class="preview-toolbar">
|
|
||||||
<div>
|
|
||||||
<span class="label">{view.toolbarLabel}</span>
|
|
||||||
<strong>{view.fileName}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="preview-actions">
|
|
||||||
<button class="secondary-btn"><Copy size={15} /> Copy CSS</button>
|
|
||||||
<button class="download-btn"
|
|
||||||
><Download size={15} />
|
|
||||||
{view.downloadLabel}
|
|
||||||
<ChevronDown size={14} /></button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="large-canvas">
|
|
||||||
<div
|
|
||||||
class="gradient-art"
|
|
||||||
style="background:{grad?.css};opacity:{grad ? grad.op / 100 : 1}"
|
|
||||||
>
|
|
||||||
<div class="art-mark">PNG</div>
|
|
||||||
<span>easy-png-tools</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="code-block">
|
|
||||||
<div>
|
|
||||||
<span class="label">GENERATED CSS</span>
|
|
||||||
<button class="icon-btn" aria-label="Copy CSS"
|
|
||||||
><Copy size={14} /></button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<pre>{grad?.code}</pre>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{:else}
|
|
||||||
<section class={view.previewClass}>
|
|
||||||
<div class="preview-toolbar">
|
|
||||||
<div>
|
|
||||||
<span class="label">{view.toolbarLabel}</span>
|
|
||||||
<strong>{view.fileName}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="preview-actions">
|
|
||||||
<span class="processed"><Check size={14} /> processed</span>
|
|
||||||
<button class="download-btn"
|
|
||||||
><Download size={15} /> Download result</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="comparison-grid">
|
|
||||||
<div class="image-card">
|
|
||||||
<div class="image-label">
|
|
||||||
<span>SOURCE</span><b>original.png</b>
|
|
||||||
</div>
|
|
||||||
<div class="remover-canvas source-canvas">
|
|
||||||
<div
|
|
||||||
class="subject subject-source"
|
|
||||||
style="background:{SUBJECT_BG};opacity:1;clip-path:{SUBJECT_CLIP}"
|
|
||||||
>
|
|
||||||
<span>OBJECT</span>
|
|
||||||
</div>
|
|
||||||
<span class="canvas-size">1200 × 800</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="image-card">
|
|
||||||
<div class="image-label">
|
|
||||||
<span>RESULT</span><b>removed-bg.png</b>
|
|
||||||
</div>
|
|
||||||
<div class="remover-canvas" style="background:{CHECKER}">
|
|
||||||
<div
|
|
||||||
class="subject"
|
|
||||||
style="background:{SUBJECT_BG};opacity:1;clip-path:{SUBJECT_CLIP}"
|
|
||||||
>
|
|
||||||
<span>PNG</span>
|
|
||||||
</div>
|
|
||||||
<span class="canvas-size">1200 × 800</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="result-meta">
|
|
||||||
<span>FORMAT <b>PNG-24</b></span>
|
|
||||||
<span>ALPHA <b>ENABLED</b></span>
|
|
||||||
<span>SIMILARITY <b>72 %</b></span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="tool-page">
|
|
||||||
<div class="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div>
|
|
||||||
<div class="tool-title">
|
|
||||||
<div>
|
|
||||||
<h1>{tool.title}</h1>
|
|
||||||
<p class="lede">{tool.description}</p>
|
|
||||||
</div>
|
|
||||||
<span class="tool-status"><i></i> LIVE PREVIEW</span>
|
|
||||||
</div>
|
|
||||||
<div class="tool-layout">
|
|
||||||
<section class="settings-panel">
|
|
||||||
<div class="panel-heading">
|
|
||||||
<div>
|
|
||||||
<span class="label">{tool.category.toUpperCase()} SETTINGS</span>
|
|
||||||
<strong>Configure output</strong>
|
|
||||||
</div>
|
|
||||||
<span class="step-type">TOOL 01</span>
|
|
||||||
</div>
|
|
||||||
<div class="settings-footer">
|
|
||||||
<span class="auto-note"><i></i> preview coming soon</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -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<string, unknown>,
|
||||||
|
) => Promise<PixelImage> | PixelImage;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function executeStep(
|
||||||
|
tool: MaybeRunnable,
|
||||||
|
img: PixelImage,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<PixelImage> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<PixelImage> {
|
||||||
|
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<string, string | number>;
|
||||||
|
};
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<PixelImage> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||||
|
void handle(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handle(request: WorkerRequest): Promise<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { executeStep } from "./executor";
|
||||||
@@ -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<string, FieldValue> {
|
|
||||||
const form: Record<string, FieldValue> = {};
|
|
||||||
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<string, PreviewToolView> = {
|
|
||||||
"linear-gradient-png": gradient,
|
|
||||||
"remove-background-png": background,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getPreviewView(tool: ToolEntry): PreviewToolView | undefined {
|
|
||||||
return PREVIEW_TOOL_VIEWS[tool.id];
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user