From b3ca31bd843558f4b3f21807df618eadb2069c8f Mon Sep 17 00:00:00 2001 From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:12:52 +0530 Subject: [PATCH 01/16] docs: plan version 2 --- CONTEXT.md | 9 ++++++++ idea.md | 26 ++++++++++++++++++++++-- implementation.md | 52 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 08516fe..b201c1f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,6 +16,11 @@ _Avoid_: converter, feature page A saved pipeline opened as a quick tool page, without the canvas. _Avoid_: custom tool, app +**Built-in tool**: +A pipeline tool that ships with Hexlode, on its own page at `/tools/`, for a job people +search for by name and repeat. +_Avoid_: preset, landing page, recipe + **Studio**: The desktop editor where users build pipelines on a canvas. _Avoid_: canvas (as the product name), workspace, editor @@ -56,6 +61,10 @@ _Avoid_: autosave, backup A pipeline exported as a `.hexlode` file. _Avoid_: recipe file, export +**Pipeline link**: +A link to Hexlode that carries a whole pipeline file, compressed, after its `#`. +_Avoid_: share URL, permalink + ### Running **Run**: diff --git a/idea.md b/idea.md index de6e82f..b9da127 100644 --- a/idea.md +++ b/idea.md @@ -93,6 +93,20 @@ Compress by quality never returns a file larger than the original. A pipeline tool is a saved Studio pipeline opened as a quick tool page. The user builds the pipeline once, then drops new images into the simple page whenever they need it. +## Built-in tools + +A built-in tool is a pipeline tool that ships with Hexlode, on its own page at `/tools/`. +It looks like a quick tool page: the drop area and images first, then the pipeline's steps and, +for a tool that needs them, the settings of one or two of its nodes, such as the watermark text. +Each page renders on the server, has its own title, description and share card, and offers Open in +the Studio to change the pipeline. + +The list stays short on purpose. A job earns a page only when people search for it by name in large +numbers, repeat it, and Hexlode does all of it on the device better than a single quick tool. No +page exists only to catch a search. A tool that follows a platform's rules, such as a marketplace's +photo requirements, names the guidance its numbers come from and the date they were checked. The +list and the reason for each entry live in `src/features/built-in-tools/`. + ## Studio The Studio is for desktop screens. Phones and narrow windows get a message that points them to the @@ -252,6 +266,13 @@ Items are images, data (JSON or text) or documents (PDF). Batch numbers match th - Nothing is stored in the browser until the user clicks Save or starts a run. - A pipeline exports to and imports from a `.hexlode` file. The file is versioned JSON, and older versions are migrated on import. +- Share copies a pipeline link: the pipeline file, compressed, after the `#` of a link to Hexlode. + The part after `#` never reaches the server, so the pipeline is not stored anywhere but the link. + Opening the link shows the pipeline as a pipeline tool page, with Save and Open in the Studio. + A pipeline too large for a link, such as one with a big watermark logo, offers its `.hexlode` + file instead. A damaged or unreadable link says so and opens nothing. +- Each saved pipeline counts its runs in the browser, and the run event carries that count, so + Hexlode can see whether people come back to their pipelines. ## Analytics @@ -267,9 +288,10 @@ A signed-in cloud mode may later run large jobs on a server, keep running after and use a credit system. Local processing stays free and unlimited. The database and account code in the repository waits for this work. -## Out of scope for version 1 +## Out of scope for now - Accounts, sign-in, cloud processing and billing. -- HEIC input, animated images and colour-profile conversion. +- A command-line runner and a GitHub Action for pipelines. +- HEIC output, animated images and colour-profile conversion. - Dither and retro effects. - The Studio on phones. diff --git a/implementation.md b/implementation.md index 3f1c200..8f46503 100644 --- a/implementation.md +++ b/implementation.md @@ -5,12 +5,16 @@ ## Goal -Version 1 is the complete local product: the home page, six quick tools, pipeline tools, and the +Version 1 was the complete local product: the home page, six quick tools, pipeline tools, and the Studio with all 33 nodes from the catalogue in [idea.md](./idea.md). It ships in three phases. Phase 1 builds the whole application and node batch 1. Phases 2 and 3 only add node batches 2 and 3. Each phase is deployable and passes its exit gate before the next phase starts. -Active phase: **Phase 3**. Phases 2 and 3 are built and pass their exit gates. +Version 2 adds phases 4 to 7 on top of version 1. They make the pipeline easy to share and find, +keep the app working offline, and open the photos people actually have. + +Active phase: **Version 2, phases 4 to 7**, built together on one branch. Phases 1 to 3 are built +and pass their exit gates. ## Phase 1: the application and node batch 1 @@ -106,6 +110,50 @@ an ICO file, which is a document ([ADR 0012](./docs/adr/0012-ico-files-are-docum each image's icons in a folder named after it. Live previews show the text of data items, such as a palette or a snippet, in the inspector. +## Phase 4: pipelines people can share + +1. Pipeline links. The Studio and every pipeline tool page copy a link that carries the whole + pipeline after `#`, compressed. The part after `#` never reaches the server. Opening the link + shows the pipeline tool page with Save and Open in the Studio. A pipeline too large for a link + offers its `.hexlode` file instead. +2. Built-in tools: pipeline tools that ship with Hexlode at `/tools/`, rendered on the + server, each with an entry in `SEO_PAGES`, a share card and a place in the sitemap. Only jobs + people search for by name and repeat; the list and the reason for each entry live in + `src/features/built-in-tools/`. A built-in tool can show the settings of a few chosen nodes. +3. The run event carries how many times a saved pipeline has run. + +Exit gate: every template survives a pipeline link unchanged, a damaged link shows a clear message, +every built-in tool runs end to end on real fixtures and is in the sitemap, and the run count rises +by one per run of a saved pipeline. + +## Phase 5: offline and installable + +1. A service worker caches the built scripts, styles and codec WASM after the first visit. Pages + load from the network first and fall back to the cache offline. A badge shows when the app is + offline. +2. Installed on desktop Chromium, Hexlode opens images and `.hexlode` files from the file manager. + Installed on Android, photos shared to Hexlode open in Strip metadata, set to remove location. + +Exit gate: a browser test loads a quick tool and the Studio offline after one online visit, files +from a share or a file launch land in the right page, and the privacy page explains the cache. + +## Phase 6: HEIC input + +HEIC and HEIF photos open wherever other images do, through a WASM decoder loaded on first use in +the worker. Hexlode decodes HEIC but does not write it. + +Exit gate: real iPhone photos decode with the right dimensions, orientation and metadata, and the +node pair matrix passes with HEIC fixtures. + +## Phase 7: remove background + +A Remove background node and quick tool cut the subject out onto transparency, on the device, with +a segmentation model run by ONNX Runtime Web on WebGPU or WASM. The model downloads once from +Hexlode's own server after the user agrees, and stays cached. + +Exit gate: masks on fixtures match a reference within a set tolerance, the node pair matrix passes +with the new node, and the estimate covers the model download and slow devices. + ## Engine design - A pipeline is a directed graph without cycles. Items flow through it one by one, so an item can From bcdcbde396c273f2daff3e88abe9a7a9af9d3724 Mon Sep 17 00:00:00 2001 From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:28:38 +0530 Subject: [PATCH 02/16] feat(pipelines): share pipelines as links and count their runs Share copies a link that carries the pipeline file, deflated, after the #, and opens it as a pipeline tool page at /tools/shared with Save and Open in the Studio. Saved pipelines count their runs, and the run event carries the count. Run events and page views can name a built-in tool. --- .../0013-pipeline-links-carry-the-pipeline.md | 11 ++ src/features/pipeline-tools/open-in-studio.ts | 10 ++ .../pipeline-tools/pipeline-tool-page.tsx | 149 +++++++++++++++--- .../shared-pipeline-tool-page.tsx | 125 +++++++++++++++ .../pipeline-tools/use-share-pipeline.ts | 47 ++++++ .../pipelines/__tests__/pipeline-link.test.ts | 100 ++++++++++++ .../pipelines/__tests__/storage.test.ts | 17 ++ src/features/pipelines/constants.ts | 11 ++ src/features/pipelines/pipeline-file.ts | 10 +- src/features/pipelines/pipeline-link.ts | 114 ++++++++++++++ src/features/pipelines/storage.ts | 13 ++ src/features/pipelines/types.ts | 2 + src/features/pipelines/validators.ts | 1 + .../__tests__/run-controller.browser.test.ts | 32 +++- src/features/runs/run-controller.ts | 13 +- src/features/runs/run-panel.tsx | 8 +- src/features/studio/studio-page.tsx | 19 ++- src/features/usage/events.ts | 26 ++- src/features/usage/use-page-view.ts | 10 +- src/lib/base64.ts | 10 ++ src/routeTree.gen.ts | 21 +++ src/routes/tools.shared.tsx | 11 ++ 22 files changed, 717 insertions(+), 43 deletions(-) create mode 100644 docs/adr/0013-pipeline-links-carry-the-pipeline.md create mode 100644 src/features/pipeline-tools/open-in-studio.ts create mode 100644 src/features/pipeline-tools/shared-pipeline-tool-page.tsx create mode 100644 src/features/pipeline-tools/use-share-pipeline.ts create mode 100644 src/features/pipelines/__tests__/pipeline-link.test.ts create mode 100644 src/features/pipelines/pipeline-link.ts create mode 100644 src/routes/tools.shared.tsx diff --git a/docs/adr/0013-pipeline-links-carry-the-pipeline.md b/docs/adr/0013-pipeline-links-carry-the-pipeline.md new file mode 100644 index 0000000..66d4984 --- /dev/null +++ b/docs/adr/0013-pipeline-links-carry-the-pipeline.md @@ -0,0 +1,11 @@ +# Pipeline links carry the whole pipeline + +A pipeline link puts the pipeline file, deflated and written in base64url, after the `#` of a link +to `/tools/shared`. Browsers never send the part after `#` to a server, so sharing a pipeline needs +no storage, no accounts and no server code, and Hexlode never holds a copy of anyone's pipeline. +The cost is length: a link stays under 7,800 characters after the `#`, which chat apps, email and +every browser keep intact. Templates make links of a few hundred characters; a pipeline with a +large watermark logo does not fit and is shared as a `.hexlode` file instead. The part after `#` +starts with its own version, apart from the file's, so the packing can change while old links keep +opening, and a link unpacks to at most 2 MB, so a crafted link cannot fill the memory. Once a cloud +mode exists, short links stored on a server can sit beside these without replacing them. diff --git a/src/features/pipeline-tools/open-in-studio.ts b/src/features/pipeline-tools/open-in-studio.ts new file mode 100644 index 0000000..0449384 --- /dev/null +++ b/src/features/pipeline-tools/open-in-studio.ts @@ -0,0 +1,10 @@ +import { draftStore } from '#/features/pipelines/draft' +import type { NamedPipeline } from '#/features/pipelines/types' + +/** + * Makes a pipeline this tab's Studio draft, unsaved, so `/studio` opens it. Call it right before + * going to the Studio. + */ +export function openInStudio(named: NamedPipeline) { + draftStore().write({ name: named.name, pipeline: named.pipeline, savedId: null, dirty: true }) +} diff --git a/src/features/pipeline-tools/pipeline-tool-page.tsx b/src/features/pipeline-tools/pipeline-tool-page.tsx index 8e50876..d526fe2 100644 --- a/src/features/pipeline-tools/pipeline-tool-page.tsx +++ b/src/features/pipeline-tools/pipeline-tool-page.tsx @@ -1,4 +1,5 @@ import { Button } from '@astryxdesign/core/Button' +import { Divider } from '@astryxdesign/core/Divider' import { EmptyState } from '@astryxdesign/core/EmptyState' import { HStack, VStack } from '@astryxdesign/core/Stack' import { Text } from '@astryxdesign/core/Text' @@ -6,35 +7,107 @@ import { useNavigate } from '@tanstack/react-router' import { useEffect, useState, useSyncExternalStore } from 'react' import { PageColumn } from '#/features/app-shell/page-column' +import type { Pipeline } from '#/features/engine/types' import { productRegistry } from '#/features/nodes/registry' +import { useSharePipeline } from '#/features/pipeline-tools/use-share-pipeline' import { validatePipeline } from '#/features/pipelines/pipeline-file' import { pipelineStore } from '#/features/pipelines/storage' -import type { SavedPipeline } from '#/features/pipelines/types' import { ToolColumns, ToolPageFrame } from '#/features/quick-tools/quick-tool-page' -import { createRunController } from '#/features/runs/run-controller' +import { createRunController, type Surface } from '#/features/runs/run-controller' import { FilesSection, ResultsSection, RunSection, ToolPanel } from '#/features/runs/run-panel' import { useController, useRunState } from '#/features/runs/use-run-controller' +import { NodeSettings } from '#/features/studio/node-settings' import { PipelineSteps } from '#/features/studio/pipeline-steps' import { usePageView } from '#/features/usage/use-page-view' -function outputsOf(pipeline: SavedPipeline) { - return pipeline.pipeline.nodes.filter((node) => node.type === 'output') +function outputsOf(pipeline: Pipeline) { + return pipeline.nodes.filter((node) => node.type === 'output') } -function PipelineTool({ saved }: { saved: SavedPipeline }) { - const outputs = outputsOf(saved) +/** Merges settings changes into one node, keeping the pipeline valid. */ +function withSettings(pipeline: Pipeline, nodeId: string, changes: Record) { + return { + ...pipeline, + nodes: pipeline.nodes.map((node) => { + if (node.id !== nodeId) return node + const merged = { ...node.settings, ...changes } + try { + productRegistry.get(node.type)?.parseSettings(merged) + } catch { + return node + } + return { ...node, settings: merged } + }), + } +} + +/** The settings of the nodes a tool lets people change, one form per node. */ +function AdjustableSettings({ + pipeline, + nodeIds, + onChange, + isDisabled, +}: { + pipeline: Pipeline + nodeIds: readonly string[] + onChange: (nodeId: string, changes: Record) => void + isDisabled: boolean +}) { + return nodeIds.map((nodeId) => { + const node = pipeline.nodes.find((candidate) => candidate.id === nodeId) + const definition = node && productRegistry.get(node.type) + if (!node || !definition) return null + return ( + + + + {definition.label} + + onChange(nodeId, changes)} + isDisabled={isDisabled} + /> + + ) + }) +} + +/** + * The body of every pipeline tool page: images on the left, the pipeline's steps, any settings the + * tool lets people change and the run button on the right, the results below. + */ +export function PipelineToolBody({ + pipeline, + surface = 'pipeline-tool', + builtInTool, + adjustable = [], + countRun, +}: { + pipeline: Pipeline + surface?: Exclude + builtInTool?: string + /** Ids of the nodes whose settings the page shows. */ + adjustable?: readonly string[] + countRun?: () => number | undefined +}) { + const [current, setCurrent] = useState(pipeline) + useEffect(() => setCurrent(pipeline), [pipeline]) + const outputs = outputsOf(current) const outputId = outputs[0]?.id ?? 'output' const controller = useController(() => createRunController({ registry: productRegistry, - surface: 'pipeline-tool', + surface, + builtInTool, outputNodeId: outputId, }), ) const state = useRunState(controller) useEffect(() => { - void controller.setPipeline(saved.pipeline) - }, [controller, saved]) + void controller.setPipeline(current) + }, [controller, current]) const count = state.sources.length if (outputs.length === 0) { @@ -53,18 +126,29 @@ function PipelineTool({ saved }: { saved: SavedPipeline }) { end={ 0 ? 'Steps and settings' : 'Steps'} footer={ 0 ? `Run on ${count} image${count === 1 ? '' : 's'}` : 'Run'} /> } > - + + + + setCurrent((previous) => withSettings(previous, nodeId, changes)) + } + isDisabled={state.running} + /> + } /> @@ -73,7 +157,7 @@ function PipelineTool({ saved }: { saved: SavedPipeline }) { ) } -function Missing({ title, description }: { title: string; description: string }) { +export function Missing({ title, description }: { title: string; description: string }) { return ( @@ -81,9 +165,21 @@ function Missing({ title, description }: { title: string; description: string }) ) } +/** Why a pipeline cannot run in this version of Hexlode, or null when it can. */ +export function pipelineProblem(pipeline: Pipeline) { + try { + validatePipeline(pipeline, productRegistry) + return null + } catch (reason) { + return reason instanceof Error ? reason.message : 'This pipeline cannot run.' + } +} + +/** A pipeline saved in this browser, opened as a tool at `/tools/`. */ export function PipelineToolPage({ pipelineId }: { pipelineId: string }) { usePageView({ page: 'pipeline-tool' }) const navigate = useNavigate() + const share = useSharePipeline() const store = pipelineStore() const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) @@ -101,12 +197,7 @@ export function PipelineToolPage({ pipelineId }: { pipelineId: string }) { /> ) } - let problem: string | null = null - try { - validatePipeline(saved.pipeline, productRegistry) - } catch (reason) { - problem = reason instanceof Error ? reason.message : 'This pipeline cannot run.' - } + const problem = pipelineProblem(saved.pipeline) if (problem) return return ( A pipeline saved in this browser, ready for new images. -