diff --git a/.gitignore b/.gitignore index 93b3bcee..f5ad3135 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ start.sh dist release cache.json +CLAUDE.md diff --git a/README.md b/README.md index 16705757..7ab9b17d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,24 @@ For a semi-technical overview of this tool, check out the video: https://youtu.b 5. Click **Convert**! 6. Hopefully, after a bit (or a lot) of thinking, the program will spit out the file you wanted. If not, see the "Issues" section below. +### Chained / waypoint conversion + +Between the "Convert from" and "Convert to" columns is a **Via (optional)** panel. It lets you force the conversion through one or more intermediate formats in a single click — no re-uploading required. + +**Example — PNG → WAV → PNG (round-trip / reinterpretation):** + +1. Drop a PNG and let the tool auto-select it as the input format. +2. Click **Round trip ↺** to automatically mirror the input as the output (PNG → PNG). +3. Click **+ Add intermediate format** and pick **WAV** from the picker. +4. Click **Convert**. One download arrives: the final PNG. + +**Tips:** + +- Add as many waypoints as you like by clicking **+ Add intermediate format** multiple times. +- Remove a waypoint by clicking **×** on its chip. +- Tick **Also download intermediate files** to also receive each leg's output (e.g. the WAV above). +- Switching between Simple / Advanced mode clears the waypoint list. + ## Issues Ever since the YouTube video released, we've been getting spammed with issues suggesting the addition of all kinds of niche file formats. To keep things organized, I've decided to specify what counts as a valid issue and what doesn't. diff --git a/index.html b/index.html index f85e0f68..dc0f15bd 100644 --- a/index.html +++ b/index.html @@ -41,6 +41,20 @@
Force conversion through a specific intermediate format
+ +Converting ${from.format.format} → ${to.format.format}…
` + ); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + } + return window.tryConvertByTraversing(files, from as any, to as any); + }; + + const result = await runChain(inputFileData, segments, segmentRunner); + + if (result.failedAt !== undefined) { + window.hidePopup(); + const failFrom = segments[result.failedAt]; + const failTo = segments[result.failedAt + 1]; + alert( + `Step ${result.failedAt + 1} of ${segments.length - 1} failed: ` + + `${failFrom.format.format} → ${failTo.format.format}` + ); + return; + } + + if (result.finalFiles.length === 0) { window.hidePopup(); alert("Failed to find conversion route."); return; } - for (const file of output.files) { + if (downloadIntermediates) { + for (let i = 0; i < result.legs.length - 1; i++) { + for (const file of result.legs[i].files) { + const dot = file.name.lastIndexOf("."); + const base = dot > 0 ? file.name.slice(0, dot) : file.name; + const ext = dot > 0 ? file.name.slice(dot) : ""; + downloadFile(file.bytes, `${base}.step${i + 1}${ext}`); + } + } + } + + for (const file of result.finalFiles) { downloadFile(file.bytes, file.name); } + const fullPath = result.legs.length > 0 + ? result.legs.flatMap((leg, i) => + i === 0 + ? leg.path.map(n => n.format.format) + : leg.path.slice(1).map(n => n.format.format) + ) + : [inputOption.format.format, outputOption.format.format]; + window.showPopup( - `Path used: ${output.path.map(c => c.format.format).join(" → ")}.
\n` + + `Path: ${fullPath.join(" → ")}
\n` + `` ); diff --git a/src/runChain.ts b/src/runChain.ts new file mode 100644 index 00000000..04dfb63b --- /dev/null +++ b/src/runChain.ts @@ -0,0 +1,85 @@ +import type { FileData, FileFormat, FormatHandler, ConvertPathNode } from "./FormatHandler.ts"; + +export type FormatOption = { format: FileFormat; handler: FormatHandler }; + +export type Leg = { + files: FileData[]; + path: ConvertPathNode[]; + from: FormatOption; + to: FormatOption; +}; + +export type ChainResult = { + finalFiles: FileData[]; + legs: Leg[]; + failedAt?: number; +}; + +/** + * Single-leg conversion primitive — `runChain` is decoupled from the DOM by + * receiving this as a callback rather than reaching for `window`. + */ +export type SegmentRunner = ( + files: FileData[], + from: FormatOption, + to: FormatOption, + legIndex: number, + totalLegs: number +) => Promise<{ files: FileData[]; path: ConvertPathNode[] } | null>; + +const stripExt = (name: string): string => { + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(0, dot) : name; +}; + +const sameFormat = (a: FormatOption, b: FormatOption): boolean => + a.format.mime === b.format.mime && a.format.format === b.format.format; + +/** + * Orchestrates a chained conversion across `[input, ...waypoints, output]`. + * Each leg is delegated to the supplied {@link SegmentRunner}, which in + * production is a thin wrapper around `window.tryConvertByTraversing` and in + * tests can be any function returning fake `FileData`/path data. + * + * Each leg's outputs are retained on the returned {@link Leg} so the caller + * can offer optional intermediate downloads without re-running anything. + */ +export async function runChain( + initialFiles: FileData[], + segments: FormatOption[], + runSegment: SegmentRunner +): Promise