feat: aligned mode for pipeline

This commit is contained in:
2026-10-07 07:26:32 +05:00
parent 47f42f5de3
commit b7be973b54
5 changed files with 346 additions and 88 deletions
-8
View File
@@ -54,14 +54,6 @@
браузере, порядок файлов, поведение many → 1 → many в одной цепочке. браузере, порядок файлов, поведение many → 1 → many в одной цепочке.
Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по
архиву), `docs/roadmap.md` фаза 8 «batch-страница». архиву), `docs/roadmap.md` фаза 8 «batch-страница».
- [ ] **Идея 17. Промежуточные результаты шагов + выравнивание рядов** — оценка
M. Два режима просмотра цепочки: - **без выравнивания (по умолчанию):** в
панели просмотра цепочка изображений по 2 в колонку «исходник → результат
1 → результат 2 → … → конечный результат»; - **с выравниванием (галочка
«выровнять инструменты и просмотр»):** у каждого инструмента показаны его
исходник и результат построчно «параметры — исходник — результат»;
промежуточные картинки при этом осознанно дублируются на экране —
результат шага является исходником следующего.
## Инструменты ## Инструменты
@@ -3,6 +3,9 @@
import SchemaActions from "./SchemaActions.svelte"; import SchemaActions from "./SchemaActions.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte"; import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte"; import SchemaSourceTile from "./SchemaSourceTile.svelte";
import PreviewTile from "./PreviewTile.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import type { OutputMime } from "$lib/core/io"; import type { OutputMime } from "$lib/core/io";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
@@ -26,6 +29,9 @@
textVars?: Record<string, string | number>; textVars?: Record<string, string | number>;
running?: boolean; running?: boolean;
error?: string; error?: string;
stepResults?: (PixelImage | null)[];
aligned?: boolean;
ontogglealign?: () => void;
format?: OutputMime; format?: OutputMime;
quality?: number; quality?: number;
alphaLoss?: boolean; alphaLoss?: boolean;
@@ -51,6 +57,9 @@
textVars = undefined, textVars = undefined,
running = false, running = false,
error = "", error = "",
stepResults = [],
aligned = false,
ontogglealign,
format = "image/png", format = "image/png",
quality = undefined, quality = undefined,
alphaLoss = false, alphaLoss = false,
@@ -71,6 +80,16 @@
<div class="panel-head"> <div class="panel-head">
<span class="label">{t("resultCard.previewPanel")}</span> <span class="label">{t("resultCard.previewPanel")}</span>
{#if stepResults.length > 1}
<label class="align-toggle">
<Toggle
checked={aligned}
label={t("chain.alignToggle")}
onchange={() => ontogglealign?.()}
/>
<span>{t("chain.alignToggle")}</span>
</label>
{/if}
<SchemaActions <SchemaActions
{inputMode} {inputMode}
{resultKind} {resultKind}
@@ -100,6 +119,17 @@
{onrendertext} {onrendertext}
{onupload} {onupload}
/> />
{#each stepResults.slice(0, -1) as img, i (i)}
{#if img}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
dims={`${img.width} × ${img.height}`}
>
<img src={toDataUrl(img)} alt="" />
</PreviewTile>
{/if}
{/each}
<SchemaResultTile <SchemaResultTile
{resultKind} {resultKind}
{result} {result}
@@ -118,7 +148,7 @@
.panel-head { .panel-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: center;
gap: var(--space-m); gap: var(--space-m);
padding-bottom: var(--space-l); padding-bottom: var(--space-l);
border-bottom: var(--size-border) solid var(--color-border); border-bottom: var(--size-border) solid var(--color-border);
@@ -130,6 +160,15 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--color-main); color: var(--color-main);
} }
.align-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin-right: auto;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.error { .error {
margin: 0 0 var(--space-l); margin: 0 0 var(--space-l);
color: var(--color-danger); color: var(--color-danger);
@@ -1,9 +1,6 @@
<script lang="ts"> <script lang="ts">
import AddStepButton from "./AddStepButton.svelte";
import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte";
import StepCard from "$lib/components/display/StepCard.svelte"; import StepCard from "$lib/components/display/StepCard.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import { hasTransparency } from "$lib/core/analyze"; import { hasTransparency } from "$lib/core/analyze";
import { debounce } from "$lib/core/debounce"; import { debounce } from "$lib/core/debounce";
import { ToolError, type ErrorVars } from "$lib/core/errors"; import { ToolError, type ErrorVars } from "$lib/core/errors";
@@ -12,6 +9,7 @@
downloadBlob, downloadBlob,
encode, encode,
outputFormatByMime, outputFormatByMime,
toDataUrl,
type OutputMime, type OutputMime,
} from "$lib/core/io"; } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
@@ -51,6 +49,14 @@
import { downloadZip } from "$lib/zip"; import { downloadZip } from "$lib/zip";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { SvelteMap, SvelteSet } from "svelte/reactivity"; import { SvelteMap, SvelteSet } from "svelte/reactivity";
import AddStepButton from "./AddStepButton.svelte";
import PreviewTile from "./PreviewTile.svelte";
import SchemaActions from "./SchemaActions.svelte";
import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte";
interface Props { interface Props {
page: Page; page: Page;
@@ -74,6 +80,8 @@
// Последняя правленая ось dimension-поля — ведущая при lockAspect. // Последняя правленая ось dimension-поля — ведущая при lockAspect.
const lastAxis: Record<string, "width" | "height"> = {}; const lastAxis: Record<string, "width" | "height"> = {};
let stepDims = $state<(Dimension | undefined)[]>([]); let stepDims = $state<(Dimension | undefined)[]>([]);
let stepResults = $state<(PixelImage | null)[]>([]);
let aligned = $state(false);
let source = $state<PixelImage | null>(null); let source = $state<PixelImage | null>(null);
let result = $state<PixelImage | null>(null); let result = $state<PixelImage | null>(null);
let fileResult = $state<FileResult | null>(null); let fileResult = $state<FileResult | null>(null);
@@ -97,6 +105,9 @@
const resultKind = $derived(lastTool.result ?? "image"); const resultKind = $derived(lastTool.result ?? "image");
// Цепочку можно продолжить, только если последний шаг отдаёт картинку. // Цепочку можно продолжить, только если последний шаг отдаёт картинку.
const canExtend = $derived((lastTool.result ?? "image") === "image"); const canExtend = $derived((lastTool.result ?? "image") === "image");
const alignedMode = $derived(
aligned && resultKind === "image" && steps.length > 1,
);
const alphaLoss = $derived( const alphaLoss = $derived(
resultKind === "image" && resultKind === "image" &&
@@ -327,6 +338,7 @@
try { try {
let current: PixelImage | undefined = source ?? undefined; let current: PixelImage | undefined = source ?? undefined;
const dims: (Dimension | undefined)[] = []; const dims: (Dimension | undefined)[] = [];
const results: (PixelImage | null)[] = [];
let out: ToolResult = ""; let out: ToolResult = "";
for (let i = 0; i < steps.length; i++) { for (let i = 0; i < steps.length; i++) {
const stepTool = getTool(steps[i].id); const stepTool = getTool(steps[i].id);
@@ -353,11 +365,14 @@
if (out && typeof out === "object" && "data" in out) { if (out && typeof out === "object" && "data" in out) {
current = out as PixelImage; current = out as PixelImage;
dims[i] = { width: current.width, height: current.height }; dims[i] = { width: current.width, height: current.height };
results[i] = current;
} else { } else {
current = undefined; current = undefined;
results[i] = null;
} }
} }
stepDims = dims; stepDims = dims;
stepResults = results;
assignResult(out); assignResult(out);
} catch (e) { } catch (e) {
displayError = toDisplayError(e); displayError = toDisplayError(e);
@@ -470,21 +485,14 @@
</div> </div>
</header> </header>
<div class="workspace"> {#snippet stepCard(step: ChainStep, i: number)}
<section class="settings">
{#if canExtend}
<AddStepButton onadd={(id) => addStepAt(0, id)} />
{/if}
{#each steps as step, i (step.key)}
<StepCard <StepCard
index={i + 1} index={i + 1}
title={stepTitle(step.id)} title={stepTitle(step.id)}
draggable draggable
collapsed={step.collapsed ?? false} collapsed={step.collapsed ?? false}
ontoggle={() => toggleStep(i)} ontoggle={() => toggleStep(i)}
onremove={steps.length > 1 onremove={steps.length > 1 ? () => removeStepAt(step.key) : undefined}
? () => removeStepAt(step.key)
: undefined}
ondragstart={(e) => { ondragstart={(e) => {
dragFrom = i; dragFrom = i;
if (e.dataTransfer) { if (e.dataTransfer) {
@@ -515,6 +523,16 @@
onreset={() => resetStep(i)} onreset={() => resetStep(i)}
/> />
</StepCard> </StepCard>
{/snippet}
{#if !alignedMode}
<div class="workspace">
<section class="settings">
{#if canExtend}
<AddStepButton onadd={(id) => addStepAt(0, id)} />
{/if}
{#each steps as step, i (step.key)}
{@render stepCard(step, i)}
{#if canExtend} {#if canExtend}
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} /> <AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
{/if} {/if}
@@ -538,6 +556,9 @@
{format} {format}
quality={currentQuality} quality={currentQuality}
{alphaLoss} {alphaLoss}
{stepResults}
{aligned}
ontogglealign={() => (aligned = !aligned)}
onformat={(v) => (format = v)} onformat={(v) => (format = v)}
onquality={setFormatQuality} onquality={setFormatQuality}
onupload={handleFile} onupload={handleFile}
@@ -551,6 +572,117 @@
/> />
</section> </section>
</div> </div>
{:else}
<div class="aligned">
<div class="aligned-row">
<div></div>
<div class="panel-segment head-segment">
<span class="label">{t("resultCard.previewPanel")}</span>
<label class="align-toggle">
<Toggle bind:checked={aligned} label={t("chain.alignToggle")} />
<span>{t("chain.alignToggle")}</span>
</label>
<SchemaActions
{inputMode}
{resultKind}
canDownload={result !== null}
{running}
{format}
quality={currentQuality}
{alphaLoss}
onupload={handleFile}
ondownload={download}
onformat={(v) => (format = v)}
onquality={setFormatQuality}
/>
</div>
</div>
{#if errorText}
<p class="error" role="alert">{errorText}</p>
{/if}
{#if canExtend}
<div class="aligned-row">
<AddStepButton onadd={(id) => addStepAt(0, id)} />
<div class="panel-segment connect-segment"></div>
</div>
{/if}
{#each steps as step, i (step.key)}
{@const input = i === 0 ? null : stepResults[i - 1]}
{@const out = stepResults[i]}
{#if i > 0}
<div class="group-divider">
<div class="gd-cell"></div>
</div>
{/if}
<div class="aligned-row">
{@render stepCard(step, i)}
<div
class="panel-segment pair-segment"
class:last-segment={i === steps.length - 1 && !canExtend}
>
{#if i === 0}
<SchemaSourceTile
mode={inputMode}
{source}
{textSource}
{running}
ontextinput={(v) => (textSource = v)}
onrendertext={run}
onupload={handleFile}
/>
{:else}
<PreviewTile
label={t("chain.inputLegend")}
viewMode="image"
dims={input ? `${input.width} × ${input.height}` : undefined}
>
{#if input}
<img src={toDataUrl(input)} alt="" />
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}
</PreviewTile>
{/if}
{#if i === steps.length - 1}
<SchemaResultTile
{resultKind}
result={out}
{resultNote}
{fileResult}
{textResult}
textVars={verdictVars}
toolId={lastTool.id}
{running}
oncopy={copyText}
ondownloadtxt={downloadText}
/>
{:else}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
dims={out ? `${out.width} × ${out.height}` : undefined}
>
{#if out}
<img src={toDataUrl(out)} alt="" />
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}
</PreviewTile>
{/if}
</div>
</div>
{#if canExtend}
<div class="aligned-row">
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
<div
class="panel-segment connect-segment"
class:add-last={i === steps.length - 1}
></div>
</div>
{/if}
{/each}
</div>
{/if}
</div> </div>
{:else} {:else}
<p class="no-schema">{t("paramsCard.noSchema")}</p> <p class="no-schema">{t("paramsCard.noSchema")}</p>
@@ -599,9 +731,100 @@
.no-schema { .no-schema {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
} }
.aligned {
display: grid;
}
.aligned-row {
display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 2fr;
gap: var(--space-xxl);
align-items: start;
}
/* Сегменты справа образуют «одну панель»: общий фон, боковые границы,
верх у шапки и низ у последней строки. */
.panel-segment {
background: var(--color-panel);
border-left: var(--size-border) solid var(--color-border);
border-right: var(--size-border) solid var(--color-border);
padding: var(--space-xl);
}
.head-segment {
display: flex;
align-items: center;
gap: var(--space-xl);
flex-wrap: wrap;
border-top: var(--size-border) solid var(--color-border);
border-bottom: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m) var(--radius-m) 0 0;
}
.head-segment .label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-main);
}
.pair-segment {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
/* Коннектор в строках «Add step»: продолжает боковые границы панели. */
.connect-segment {
align-self: stretch;
padding: 0;
}
.connect-segment.add-last {
border-bottom: var(--size-border) solid var(--color-border);
border-radius: 0 0 var(--radius-m) var(--radius-m);
}
/* Полоса-разделитель между группами: ячейки с фоном и боковыми границами
(как у карточки слева и панели справа), сверху — линия через всю ширину. */
.group-divider {
position: relative;
display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 2fr;
gap: var(--space-xxl);
}
.gd-cell {
grid-column: 2;
background: var(--color-panel);
border-left: var(--size-border) solid var(--color-border);
border-right: var(--size-border) solid var(--color-border);
padding: var(--space-s) 0;
}
.group-divider::after {
content: "";
position: absolute;
inset: 0 0 auto;
border-top: var(--size-border) solid var(--color-border);
}
.last-segment {
border-bottom: var(--size-border) solid var(--color-border);
border-radius: 0 0 var(--radius-m) var(--radius-m);
}
.align-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin-right: auto;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.error {
margin: 0;
color: var(--color-danger);
font: var(--font-size-s) var(--font-mono);
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
@media (--bp-tablet) { @media (--bp-tablet) {
.workspace { .workspace {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.aligned-row {
grid-template-columns: 1fr;
}
} }
</style> </style>
+2
View File
@@ -52,6 +52,8 @@ export const en: Dict = {
changeTool: "Change tool", changeTool: "Change tool",
collapseStep: "Collapse step", collapseStep: "Collapse step",
expandStep: "Expand step", expandStep: "Expand step",
stepResult: "Result {n}",
alignToggle: "Align tools and preview",
inputLegend: "Input", inputLegend: "Input",
resultLegend: "Result", resultLegend: "Result",
paramsLegend: "Parameters", paramsLegend: "Parameters",
+2
View File
@@ -53,6 +53,8 @@ export const ru: Dict = {
changeTool: "Поменять инструмент", changeTool: "Поменять инструмент",
collapseStep: "Свернуть шаг", collapseStep: "Свернуть шаг",
expandStep: "Развернуть шаг", expandStep: "Развернуть шаг",
stepResult: "Результат {n}",
alignToggle: "Выровнять инструменты и просмотр",
inputLegend: "Вход", inputLegend: "Вход",
resultLegend: "Результат", resultLegend: "Результат",
paramsLegend: "Параметры", paramsLegend: "Параметры",