feat: add local image processing workflow canvas - #1

Draft
devchaudhary24k wants to merge 7 commits into
mainfrom
dev
Draft

feat: add local image processing workflow canvas#1
devchaudhary24k wants to merge 7 commits into
mainfrom
dev

Conversation

@devchaudhary24k

@devchaudhary24kdevchaudhary24k commented Aug 4, 2026

Copy link
Copy Markdown
Member

What changed

  • adds validated JPEG/PNG input with allocation limits
  • processes resize and WebP encoding in a dedicated browser Web Worker
  • adds cancellation, structured progress/errors, comparison, and download
  • adds the six-node editable React Flow starter pipeline
  • adds connection validation, inspector settings, reconnect/delete/restore, undo/redo, and keyboard controls
  • disables PostHog autocapture and session recording for local file privacy

Code-quality and correctness

  • colocates tests in feature-owned __tests__/ directories
  • separates workflow graph constants, types, validation, canvas rendering, toolbar, inspector, and model code
  • separates image and processing limits, domain types, and validators
  • centralizes byte formatting and adds an HTTP-compatible random-ID fallback
  • invalidates stale output when resize or quality settings change
  • rejects truncated or malformed PNG IHDR headers before decode
  • keeps connection preview and drop validation consistent without breaking reconnection
  • validates worker requests before creating active jobs
  • rejects synchronous worker-transfer failures through the shared cleanup path
  • documents the code-organization rules in AGENTS.md

Why native Web Workers

The image pipeline is intentionally browser-local. BullMQ requires a Node.js service and Redis, which would upload or remotely process files and violate the current local-first scope.

Validation

  • pnpm validate
  • 16 tests across 6 focused test files
  • TypeScript and Biome pass
  • production client, SSR, and Nitro builds pass

Validation limitation

A browser automation smoke check could not run on this machine because no Chromium binary is installed.

Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/image-input/validators.ts
Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
}: CreateRecipeInput): Recipe {
const timestamp = new Date().toISOString()

return parseRecipe({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/recipe.ts:26

createRecipe calls parseRecipe synchronously, so when renameTemplate is invalid (e.g. empty or too long), it throws from the click handler before persist(...) is ever called. Because the save UI only attaches .catch() to the persist(...) promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting createRecipe inside the async save flow so its validation error is caught by the same .catch() handler, or validate renameTemplate before calling createRecipe.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/recipe.ts around line 26:
`createRecipe` calls `parseRecipe` synchronously, so when `renameTemplate` is invalid (e.g. empty or too long), it throws from the click handler before `persist(...)` is ever called. Because the save UI only attaches `.catch()` to the `persist(...)` promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting `createRecipe` inside the async save flow so its validation error is caught by the same `.catch()` handler, or validate `renameTemplate` before calling `createRecipe`.

return typeof window !== 'undefined' && 'showDirectoryPicker' in window
}

export async function pickFolderImages() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumimage-input/folder.ts:26

pickFolderImages traverses the entire directory tree and calls handle.getFile() for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a File object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into collectFiles so it stops walking the tree once enough images are collected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/image-input/folder.ts around line 26:
`pickFolderImages` traverses the entire directory tree and calls `handle.getFile()` for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a `File` object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into `collectFiles` so it stops walking the tree once enough images are collected.

})
}

async function withStore<T>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/local-store.ts:42

withStore resolves when the individual IDBRequest succeeds, not when the surrounding transaction commits. A readwrite transaction can still abort after put/delete succeeds, but saveRecipe, savePreferences, and saveRun will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its complete event before resolving; reject on abort or error.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/local-store.ts around line 42:
`withStore` resolves when the individual `IDBRequest` succeeds, not when the surrounding transaction commits. A `readwrite` transaction can still abort after `put`/`delete` succeeds, but `saveRecipe`, `savePreferences`, and `saveRun` will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its `complete` event before resolving; reject on `abort` or `error`.

const PrivacyContext = createContext<PrivacyContextValue | null>(null)

export function PrivacyProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprivacy/privacy-provider.tsx:17

If a user changes a privacy setting before loadPreferences resolves, the stored value overwrites their newer choice. update calls setPreferences synchronously, but the useEffect callback later calls setPreferences(stored) unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

 export function PrivacyProvider({ children }: { children: ReactNode }) {
- const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const userModifiedRef = useRef(false)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/privacy/privacy-provider.tsx around line 17:
If a user changes a privacy setting before `loadPreferences` resolves, the stored value overwrites their newer choice. `update` calls `setPreferences` synchronously, but the `useEffect` callback later calls `setPreferences(stored)` unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

let outputName = `${prefix}${stem}.${extension}`
let collision = 2

while (used.has(outputName.toLocaleLowerCase())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumprocessing/output.ts:39

createOutputNames uses toLocaleLowerCase() for collision detection, so in Turkish locales I.png and i.png normalize to different keys (ı and i). Both files keep colliding output names instead of the second getting a -2 suffix, defeating the dedup logic. Use toLowerCase() instead so normalization is locale-independent.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output.ts around line 39:
`createOutputNames` uses `toLocaleLowerCase()` for collision detection, so in Turkish locales `I.png` and `i.png` normalize to different keys (`ı` and `i`). Both files keep colliding output names instead of the second getting a `-2` suffix, defeating the dedup logic. Use `toLowerCase()` instead so normalization is locale-independent.

Comment on lines +332 to +355
try {
for (const { file, relativePath } of files.slice(0, MAX_BATCH_FILES)) {
try {
const info = inspectImageHeader(await file.arrayBuffer(), file.type)
if (selectionId !== selectionIdRef.current) return
valid.push({
id: createId(),
file,
info,
progress: 0,
relativePath,
status: 'ready',
})
} catch (reason) {
const message =
reason instanceof ImageValidationError
? reason.message
: 'The image could not be inspected.'
rejected.push(`${relativePath}: ${message}`)
}
}

setBatchItems(valid)
const first = valid[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcanvas/canvas-workbench.tsx:332

In inspectFiles, when a newer file selection supersedes an older one while the older selection's file.arrayBuffer() or inspectImageHeader call is still pending, a validation failure in the older call triggers its catch block which pushes to rejected and then falls through to setBatchItems(valid), setError, and setFailedNodeId — overwriting the newer selection's state with stale data. The selectionId guard is only checked inside the try after successful validation, not in the catch block or before the final setBatchItems/setError calls. Consider rechecking selectionId in the catch block and before committing final state.

- } catch (reason) {- const message =- reason instanceof ImageValidationError- ? reason.message- : 'The image could not be inspected.'- rejected.push(`${relativePath}: ${message}`)- }+ } catch (reason) {+ if (selectionId !== selectionIdRef.current) return+ const message =+ reason instanceof ImageValidationError+ ? reason.message+ : 'The image could not be inspected.'+ rejected.push(`${relativePath}: ${message}`)+ }
}
- setBatchItems(valid)+ if (selectionId !== selectionIdRef.current) return+ setBatchItems(valid)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 332-355:
In `inspectFiles`, when a newer file selection supersedes an older one while the older selection's `file.arrayBuffer()` or `inspectImageHeader` call is still pending, a validation failure in the older call triggers its `catch` block which pushes to `rejected` and then falls through to `setBatchItems(valid)`, `setError`, and `setFailedNodeId` — overwriting the newer selection's state with stale data. The `selectionId` guard is only checked inside the `try` after successful validation, not in the `catch` block or before the final `setBatchItems`/`setError` calls. Consider rechecking `selectionId` in the `catch` block and before committing final state.

Comment on lines +942 to +951
recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
setGraphIssue('Restore and reconnect missing nodes before running.')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highcanvas/canvas-workbench.tsx:942

deleteSelected mutates the graph but never invalidates the completed run state, so runState stays complete and the Run button stays hidden even though the modified workflow needs to be rerun. The old result also continues to appear valid for a graph it no longer matches. Connect/reconnect, addMissingNode, applyRecipe, undo, and redo have the same stale-output problem — they all mutate the graph without calling invalidateResult(). Consider calling invalidateResult() (or at least resetting runState) after every graph mutation.

 recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
+ invalidateResult()
setGraphIssue('Restore and reconnect missing nodes before running.')
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 942-951:
`deleteSelected` mutates the graph but never invalidates the completed run state, so `runState` stays `complete` and the Run button stays hidden even though the modified workflow needs to be rerun. The old `result` also continues to appear valid for a graph it no longer matches. Connect/reconnect, `addMissingNode`, `applyRecipe`, `undo`, and `redo` have the same stale-output problem — they all mutate the graph without calling `invalidateResult()`. Consider calling `invalidateResult()` (or at least resetting `runState`) after every graph mutation.

Comment on lines +83 to +88
<Button
label="Use"
variant="secondary"
size="sm"
onClick={() => onUseTournamentResult(entry.format)}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcomparison/image-lab.tsx:83

The Use button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls onUseTournamentResult, which sets the shared run state to complete and re-enables processing actions while runTournament is still processing later codecs. This causes conflicting progress and run-state updates. The Use buttons in the tournament list should be disabled when isBusy is true.

 <Button
label="Use"
variant="secondary"
size="sm"
- onClick={() => onUseTournamentResult(entry.format)}+ isDisabled={isBusy}+ onClick={() => onUseTournamentResult(entry.format)}
/>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/comparison/image-lab.tsx around lines 83-88:
The `Use` button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls `onUseTournamentResult`, which sets the shared run state to `complete` and re-enables processing actions while `runTournament` is still processing later codecs. This causes conflicting progress and run-state updates. The `Use` buttons in the tournament list should be disabled when `isBusy` is true.

}

export async function writeOutputsToFolder(outputs: OutputFile[]) {
const picker = (window as FolderPickerWindow).showDirectoryPicker

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprocessing/output-delivery.ts:27

writeOutputsToFolder extracts window.showDirectoryPicker into a standalone variable and then invokes it without the window receiver, which throws TypeError: Illegal invocation in supporting browsers and prevents every folder-save operation from completing. Call the method directly as window.showDirectoryPicker() or bind it to window before calling.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output-delivery.ts around line 27:
`writeOutputsToFolder` extracts `window.showDirectoryPicker` into a standalone variable and then invokes it without the `window` receiver, which throws `TypeError: Illegal invocation` in supporting browsers and prevents every folder-save operation from completing. Call the method directly as `window.showDirectoryPicker()` or bind it to `window` before calling.

Comment threadsrc/styles.css
overflow: hidden;
}

.difference-preview__source {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumsrc/styles.css:42

The source image inside .astryx-overlay-scrim is sized to width: 100% of that wrapper, but the wrapper's width is reduced to var(--difference-reveal). At a 50% split the source image is squeezed into a half-width box and scaled by object-fit: contain instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching .difference-preview) rather than 100% of the scrim.

Also found in 1 other location(s)

src/features/comparison/image-comparison.tsx:79

The slider comparison places the source image inside .astryx-overlay-scrim, while the stylesheet changes that wrapper's width to --difference-reveal and gives its image width: 100%. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/styles.css around line 42:
The source image inside `.astryx-overlay-scrim` is sized to `width: 100%` of that wrapper, but the wrapper's width is reduced to `var(--difference-reveal)`. At a 50% split the source image is squeezed into a half-width box and scaled by `object-fit: contain` instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching `.difference-preview`) rather than `100%` of the scrim.
Also found in 1 other location(s):
- src/features/comparison/image-comparison.tsx:79 -- The slider comparison places the source image inside `.astryx-overlay-scrim`, while the stylesheet changes that wrapper's width to `--difference-reveal` and gives its image `width: 100%`. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@devchaudhary24k
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: add local image processing workflow canvas - #1

Draft
devchaudhary24k wants to merge 7 commits into
mainfrom
dev
Draft

feat: add local image processing workflow canvas#1
devchaudhary24k wants to merge 7 commits into
mainfrom
dev

Conversation

@devchaudhary24k

@devchaudhary24kdevchaudhary24k commented Aug 4, 2026

Copy link
Copy Markdown
Member

What changed

  • adds validated JPEG/PNG input with allocation limits
  • processes resize and WebP encoding in a dedicated browser Web Worker
  • adds cancellation, structured progress/errors, comparison, and download
  • adds the six-node editable React Flow starter pipeline
  • adds connection validation, inspector settings, reconnect/delete/restore, undo/redo, and keyboard controls
  • disables PostHog autocapture and session recording for local file privacy

Code-quality and correctness

  • colocates tests in feature-owned __tests__/ directories
  • separates workflow graph constants, types, validation, canvas rendering, toolbar, inspector, and model code
  • separates image and processing limits, domain types, and validators
  • centralizes byte formatting and adds an HTTP-compatible random-ID fallback
  • invalidates stale output when resize or quality settings change
  • rejects truncated or malformed PNG IHDR headers before decode
  • keeps connection preview and drop validation consistent without breaking reconnection
  • validates worker requests before creating active jobs
  • rejects synchronous worker-transfer failures through the shared cleanup path
  • documents the code-organization rules in AGENTS.md

Why native Web Workers

The image pipeline is intentionally browser-local. BullMQ requires a Node.js service and Redis, which would upload or remotely process files and violate the current local-first scope.

Validation

  • pnpm validate
  • 16 tests across 6 focused test files
  • TypeScript and Biome pass
  • production client, SSR, and Nitro builds pass

Validation limitation

A browser automation smoke check could not run on this machine because no Chromium binary is installed.

Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/image-input/validators.ts
Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
}: CreateRecipeInput): Recipe {
const timestamp = new Date().toISOString()

return parseRecipe({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/recipe.ts:26

createRecipe calls parseRecipe synchronously, so when renameTemplate is invalid (e.g. empty or too long), it throws from the click handler before persist(...) is ever called. Because the save UI only attaches .catch() to the persist(...) promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting createRecipe inside the async save flow so its validation error is caught by the same .catch() handler, or validate renameTemplate before calling createRecipe.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/recipe.ts around line 26:
`createRecipe` calls `parseRecipe` synchronously, so when `renameTemplate` is invalid (e.g. empty or too long), it throws from the click handler before `persist(...)` is ever called. Because the save UI only attaches `.catch()` to the `persist(...)` promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting `createRecipe` inside the async save flow so its validation error is caught by the same `.catch()` handler, or validate `renameTemplate` before calling `createRecipe`.

return typeof window !== 'undefined' && 'showDirectoryPicker' in window
}

export async function pickFolderImages() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumimage-input/folder.ts:26

pickFolderImages traverses the entire directory tree and calls handle.getFile() for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a File object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into collectFiles so it stops walking the tree once enough images are collected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/image-input/folder.ts around line 26:
`pickFolderImages` traverses the entire directory tree and calls `handle.getFile()` for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a `File` object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into `collectFiles` so it stops walking the tree once enough images are collected.

})
}

async function withStore<T>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/local-store.ts:42

withStore resolves when the individual IDBRequest succeeds, not when the surrounding transaction commits. A readwrite transaction can still abort after put/delete succeeds, but saveRecipe, savePreferences, and saveRun will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its complete event before resolving; reject on abort or error.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/local-store.ts around line 42:
`withStore` resolves when the individual `IDBRequest` succeeds, not when the surrounding transaction commits. A `readwrite` transaction can still abort after `put`/`delete` succeeds, but `saveRecipe`, `savePreferences`, and `saveRun` will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its `complete` event before resolving; reject on `abort` or `error`.

const PrivacyContext = createContext<PrivacyContextValue | null>(null)

export function PrivacyProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprivacy/privacy-provider.tsx:17

If a user changes a privacy setting before loadPreferences resolves, the stored value overwrites their newer choice. update calls setPreferences synchronously, but the useEffect callback later calls setPreferences(stored) unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

 export function PrivacyProvider({ children }: { children: ReactNode }) {
- const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const userModifiedRef = useRef(false)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/privacy/privacy-provider.tsx around line 17:
If a user changes a privacy setting before `loadPreferences` resolves, the stored value overwrites their newer choice. `update` calls `setPreferences` synchronously, but the `useEffect` callback later calls `setPreferences(stored)` unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

let outputName = `${prefix}${stem}.${extension}`
let collision = 2

while (used.has(outputName.toLocaleLowerCase())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumprocessing/output.ts:39

createOutputNames uses toLocaleLowerCase() for collision detection, so in Turkish locales I.png and i.png normalize to different keys (ı and i). Both files keep colliding output names instead of the second getting a -2 suffix, defeating the dedup logic. Use toLowerCase() instead so normalization is locale-independent.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output.ts around line 39:
`createOutputNames` uses `toLocaleLowerCase()` for collision detection, so in Turkish locales `I.png` and `i.png` normalize to different keys (`ı` and `i`). Both files keep colliding output names instead of the second getting a `-2` suffix, defeating the dedup logic. Use `toLowerCase()` instead so normalization is locale-independent.

Comment on lines +332 to +355
try {
for (const { file, relativePath } of files.slice(0, MAX_BATCH_FILES)) {
try {
const info = inspectImageHeader(await file.arrayBuffer(), file.type)
if (selectionId !== selectionIdRef.current) return
valid.push({
id: createId(),
file,
info,
progress: 0,
relativePath,
status: 'ready',
})
} catch (reason) {
const message =
reason instanceof ImageValidationError
? reason.message
: 'The image could not be inspected.'
rejected.push(`${relativePath}: ${message}`)
}
}

setBatchItems(valid)
const first = valid[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcanvas/canvas-workbench.tsx:332

In inspectFiles, when a newer file selection supersedes an older one while the older selection's file.arrayBuffer() or inspectImageHeader call is still pending, a validation failure in the older call triggers its catch block which pushes to rejected and then falls through to setBatchItems(valid), setError, and setFailedNodeId — overwriting the newer selection's state with stale data. The selectionId guard is only checked inside the try after successful validation, not in the catch block or before the final setBatchItems/setError calls. Consider rechecking selectionId in the catch block and before committing final state.

- } catch (reason) {- const message =- reason instanceof ImageValidationError- ? reason.message- : 'The image could not be inspected.'- rejected.push(`${relativePath}: ${message}`)- }+ } catch (reason) {+ if (selectionId !== selectionIdRef.current) return+ const message =+ reason instanceof ImageValidationError+ ? reason.message+ : 'The image could not be inspected.'+ rejected.push(`${relativePath}: ${message}`)+ }
}
- setBatchItems(valid)+ if (selectionId !== selectionIdRef.current) return+ setBatchItems(valid)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 332-355:
In `inspectFiles`, when a newer file selection supersedes an older one while the older selection's `file.arrayBuffer()` or `inspectImageHeader` call is still pending, a validation failure in the older call triggers its `catch` block which pushes to `rejected` and then falls through to `setBatchItems(valid)`, `setError`, and `setFailedNodeId` — overwriting the newer selection's state with stale data. The `selectionId` guard is only checked inside the `try` after successful validation, not in the `catch` block or before the final `setBatchItems`/`setError` calls. Consider rechecking `selectionId` in the `catch` block and before committing final state.

Comment on lines +942 to +951
recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
setGraphIssue('Restore and reconnect missing nodes before running.')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highcanvas/canvas-workbench.tsx:942

deleteSelected mutates the graph but never invalidates the completed run state, so runState stays complete and the Run button stays hidden even though the modified workflow needs to be rerun. The old result also continues to appear valid for a graph it no longer matches. Connect/reconnect, addMissingNode, applyRecipe, undo, and redo have the same stale-output problem — they all mutate the graph without calling invalidateResult(). Consider calling invalidateResult() (or at least resetting runState) after every graph mutation.

 recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
+ invalidateResult()
setGraphIssue('Restore and reconnect missing nodes before running.')
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 942-951:
`deleteSelected` mutates the graph but never invalidates the completed run state, so `runState` stays `complete` and the Run button stays hidden even though the modified workflow needs to be rerun. The old `result` also continues to appear valid for a graph it no longer matches. Connect/reconnect, `addMissingNode`, `applyRecipe`, `undo`, and `redo` have the same stale-output problem — they all mutate the graph without calling `invalidateResult()`. Consider calling `invalidateResult()` (or at least resetting `runState`) after every graph mutation.

Comment on lines +83 to +88
<Button
label="Use"
variant="secondary"
size="sm"
onClick={() => onUseTournamentResult(entry.format)}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcomparison/image-lab.tsx:83

The Use button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls onUseTournamentResult, which sets the shared run state to complete and re-enables processing actions while runTournament is still processing later codecs. This causes conflicting progress and run-state updates. The Use buttons in the tournament list should be disabled when isBusy is true.

 <Button
label="Use"
variant="secondary"
size="sm"
- onClick={() => onUseTournamentResult(entry.format)}+ isDisabled={isBusy}+ onClick={() => onUseTournamentResult(entry.format)}
/>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/comparison/image-lab.tsx around lines 83-88:
The `Use` button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls `onUseTournamentResult`, which sets the shared run state to `complete` and re-enables processing actions while `runTournament` is still processing later codecs. This causes conflicting progress and run-state updates. The `Use` buttons in the tournament list should be disabled when `isBusy` is true.

}

export async function writeOutputsToFolder(outputs: OutputFile[]) {
const picker = (window as FolderPickerWindow).showDirectoryPicker

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprocessing/output-delivery.ts:27

writeOutputsToFolder extracts window.showDirectoryPicker into a standalone variable and then invokes it without the window receiver, which throws TypeError: Illegal invocation in supporting browsers and prevents every folder-save operation from completing. Call the method directly as window.showDirectoryPicker() or bind it to window before calling.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output-delivery.ts around line 27:
`writeOutputsToFolder` extracts `window.showDirectoryPicker` into a standalone variable and then invokes it without the `window` receiver, which throws `TypeError: Illegal invocation` in supporting browsers and prevents every folder-save operation from completing. Call the method directly as `window.showDirectoryPicker()` or bind it to `window` before calling.

Comment threadsrc/styles.css
overflow: hidden;
}

.difference-preview__source {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumsrc/styles.css:42

The source image inside .astryx-overlay-scrim is sized to width: 100% of that wrapper, but the wrapper's width is reduced to var(--difference-reveal). At a 50% split the source image is squeezed into a half-width box and scaled by object-fit: contain instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching .difference-preview) rather than 100% of the scrim.

Also found in 1 other location(s)

src/features/comparison/image-comparison.tsx:79

The slider comparison places the source image inside .astryx-overlay-scrim, while the stylesheet changes that wrapper's width to --difference-reveal and gives its image width: 100%. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/styles.css around line 42:
The source image inside `.astryx-overlay-scrim` is sized to `width: 100%` of that wrapper, but the wrapper's width is reduced to `var(--difference-reveal)`. At a 50% split the source image is squeezed into a half-width box and scaled by `object-fit: contain` instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching `.difference-preview`) rather than `100%` of the scrim.
Also found in 1 other location(s):
- src/features/comparison/image-comparison.tsx:79 -- The slider comparison places the source image inside `.astryx-overlay-scrim`, while the stylesheet changes that wrapper's width to `--difference-reveal` and gives its image `width: 100%`. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@devchaudhary24k
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: add local image processing workflow canvas - #1

Draft
devchaudhary24k wants to merge 7 commits into
mainfrom
dev
Draft

feat: add local image processing workflow canvas#1
devchaudhary24k wants to merge 7 commits into
mainfrom
dev

Conversation

@devchaudhary24k

@devchaudhary24kdevchaudhary24k commented Aug 4, 2026

Copy link
Copy Markdown
Member

What changed

  • adds validated JPEG/PNG input with allocation limits
  • processes resize and WebP encoding in a dedicated browser Web Worker
  • adds cancellation, structured progress/errors, comparison, and download
  • adds the six-node editable React Flow starter pipeline
  • adds connection validation, inspector settings, reconnect/delete/restore, undo/redo, and keyboard controls
  • disables PostHog autocapture and session recording for local file privacy

Code-quality and correctness

  • colocates tests in feature-owned __tests__/ directories
  • separates workflow graph constants, types, validation, canvas rendering, toolbar, inspector, and model code
  • separates image and processing limits, domain types, and validators
  • centralizes byte formatting and adds an HTTP-compatible random-ID fallback
  • invalidates stale output when resize or quality settings change
  • rejects truncated or malformed PNG IHDR headers before decode
  • keeps connection preview and drop validation consistent without breaking reconnection
  • validates worker requests before creating active jobs
  • rejects synchronous worker-transfer failures through the shared cleanup path
  • documents the code-organization rules in AGENTS.md

Why native Web Workers

The image pipeline is intentionally browser-local. BullMQ requires a Node.js service and Redis, which would upload or remotely process files and violate the current local-first scope.

Validation

  • pnpm validate
  • 16 tests across 6 focused test files
  • TypeScript and Biome pass
  • production client, SSR, and Nitro builds pass

Validation limitation

A browser automation smoke check could not run on this machine because no Chromium binary is installed.

Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/image-input/validators.ts
Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
}: CreateRecipeInput): Recipe {
const timestamp = new Date().toISOString()

return parseRecipe({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/recipe.ts:26

createRecipe calls parseRecipe synchronously, so when renameTemplate is invalid (e.g. empty or too long), it throws from the click handler before persist(...) is ever called. Because the save UI only attaches .catch() to the persist(...) promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting createRecipe inside the async save flow so its validation error is caught by the same .catch() handler, or validate renameTemplate before calling createRecipe.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/recipe.ts around line 26:
`createRecipe` calls `parseRecipe` synchronously, so when `renameTemplate` is invalid (e.g. empty or too long), it throws from the click handler before `persist(...)` is ever called. Because the save UI only attaches `.catch()` to the `persist(...)` promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting `createRecipe` inside the async save flow so its validation error is caught by the same `.catch()` handler, or validate `renameTemplate` before calling `createRecipe`.

return typeof window !== 'undefined' && 'showDirectoryPicker' in window
}

export async function pickFolderImages() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumimage-input/folder.ts:26

pickFolderImages traverses the entire directory tree and calls handle.getFile() for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a File object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into collectFiles so it stops walking the tree once enough images are collected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/image-input/folder.ts around line 26:
`pickFolderImages` traverses the entire directory tree and calls `handle.getFile()` for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a `File` object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into `collectFiles` so it stops walking the tree once enough images are collected.

})
}

async function withStore<T>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/local-store.ts:42

withStore resolves when the individual IDBRequest succeeds, not when the surrounding transaction commits. A readwrite transaction can still abort after put/delete succeeds, but saveRecipe, savePreferences, and saveRun will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its complete event before resolving; reject on abort or error.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/local-store.ts around line 42:
`withStore` resolves when the individual `IDBRequest` succeeds, not when the surrounding transaction commits. A `readwrite` transaction can still abort after `put`/`delete` succeeds, but `saveRecipe`, `savePreferences`, and `saveRun` will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its `complete` event before resolving; reject on `abort` or `error`.

const PrivacyContext = createContext<PrivacyContextValue | null>(null)

export function PrivacyProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprivacy/privacy-provider.tsx:17

If a user changes a privacy setting before loadPreferences resolves, the stored value overwrites their newer choice. update calls setPreferences synchronously, but the useEffect callback later calls setPreferences(stored) unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

 export function PrivacyProvider({ children }: { children: ReactNode }) {
- const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const userModifiedRef = useRef(false)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/privacy/privacy-provider.tsx around line 17:
If a user changes a privacy setting before `loadPreferences` resolves, the stored value overwrites their newer choice. `update` calls `setPreferences` synchronously, but the `useEffect` callback later calls `setPreferences(stored)` unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

let outputName = `${prefix}${stem}.${extension}`
let collision = 2

while (used.has(outputName.toLocaleLowerCase())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumprocessing/output.ts:39

createOutputNames uses toLocaleLowerCase() for collision detection, so in Turkish locales I.png and i.png normalize to different keys (ı and i). Both files keep colliding output names instead of the second getting a -2 suffix, defeating the dedup logic. Use toLowerCase() instead so normalization is locale-independent.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output.ts around line 39:
`createOutputNames` uses `toLocaleLowerCase()` for collision detection, so in Turkish locales `I.png` and `i.png` normalize to different keys (`ı` and `i`). Both files keep colliding output names instead of the second getting a `-2` suffix, defeating the dedup logic. Use `toLowerCase()` instead so normalization is locale-independent.

Comment on lines +332 to +355
try {
for (const { file, relativePath } of files.slice(0, MAX_BATCH_FILES)) {
try {
const info = inspectImageHeader(await file.arrayBuffer(), file.type)
if (selectionId !== selectionIdRef.current) return
valid.push({
id: createId(),
file,
info,
progress: 0,
relativePath,
status: 'ready',
})
} catch (reason) {
const message =
reason instanceof ImageValidationError
? reason.message
: 'The image could not be inspected.'
rejected.push(`${relativePath}: ${message}`)
}
}

setBatchItems(valid)
const first = valid[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcanvas/canvas-workbench.tsx:332

In inspectFiles, when a newer file selection supersedes an older one while the older selection's file.arrayBuffer() or inspectImageHeader call is still pending, a validation failure in the older call triggers its catch block which pushes to rejected and then falls through to setBatchItems(valid), setError, and setFailedNodeId — overwriting the newer selection's state with stale data. The selectionId guard is only checked inside the try after successful validation, not in the catch block or before the final setBatchItems/setError calls. Consider rechecking selectionId in the catch block and before committing final state.

- } catch (reason) {- const message =- reason instanceof ImageValidationError- ? reason.message- : 'The image could not be inspected.'- rejected.push(`${relativePath}: ${message}`)- }+ } catch (reason) {+ if (selectionId !== selectionIdRef.current) return+ const message =+ reason instanceof ImageValidationError+ ? reason.message+ : 'The image could not be inspected.'+ rejected.push(`${relativePath}: ${message}`)+ }
}
- setBatchItems(valid)+ if (selectionId !== selectionIdRef.current) return+ setBatchItems(valid)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 332-355:
In `inspectFiles`, when a newer file selection supersedes an older one while the older selection's `file.arrayBuffer()` or `inspectImageHeader` call is still pending, a validation failure in the older call triggers its `catch` block which pushes to `rejected` and then falls through to `setBatchItems(valid)`, `setError`, and `setFailedNodeId` — overwriting the newer selection's state with stale data. The `selectionId` guard is only checked inside the `try` after successful validation, not in the `catch` block or before the final `setBatchItems`/`setError` calls. Consider rechecking `selectionId` in the `catch` block and before committing final state.

Comment on lines +942 to +951
recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
setGraphIssue('Restore and reconnect missing nodes before running.')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highcanvas/canvas-workbench.tsx:942

deleteSelected mutates the graph but never invalidates the completed run state, so runState stays complete and the Run button stays hidden even though the modified workflow needs to be rerun. The old result also continues to appear valid for a graph it no longer matches. Connect/reconnect, addMissingNode, applyRecipe, undo, and redo have the same stale-output problem — they all mutate the graph without calling invalidateResult(). Consider calling invalidateResult() (or at least resetting runState) after every graph mutation.

 recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
+ invalidateResult()
setGraphIssue('Restore and reconnect missing nodes before running.')
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 942-951:
`deleteSelected` mutates the graph but never invalidates the completed run state, so `runState` stays `complete` and the Run button stays hidden even though the modified workflow needs to be rerun. The old `result` also continues to appear valid for a graph it no longer matches. Connect/reconnect, `addMissingNode`, `applyRecipe`, `undo`, and `redo` have the same stale-output problem — they all mutate the graph without calling `invalidateResult()`. Consider calling `invalidateResult()` (or at least resetting `runState`) after every graph mutation.

Comment on lines +83 to +88
<Button
label="Use"
variant="secondary"
size="sm"
onClick={() => onUseTournamentResult(entry.format)}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcomparison/image-lab.tsx:83

The Use button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls onUseTournamentResult, which sets the shared run state to complete and re-enables processing actions while runTournament is still processing later codecs. This causes conflicting progress and run-state updates. The Use buttons in the tournament list should be disabled when isBusy is true.

 <Button
label="Use"
variant="secondary"
size="sm"
- onClick={() => onUseTournamentResult(entry.format)}+ isDisabled={isBusy}+ onClick={() => onUseTournamentResult(entry.format)}
/>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/comparison/image-lab.tsx around lines 83-88:
The `Use` button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls `onUseTournamentResult`, which sets the shared run state to `complete` and re-enables processing actions while `runTournament` is still processing later codecs. This causes conflicting progress and run-state updates. The `Use` buttons in the tournament list should be disabled when `isBusy` is true.

}

export async function writeOutputsToFolder(outputs: OutputFile[]) {
const picker = (window as FolderPickerWindow).showDirectoryPicker

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprocessing/output-delivery.ts:27

writeOutputsToFolder extracts window.showDirectoryPicker into a standalone variable and then invokes it without the window receiver, which throws TypeError: Illegal invocation in supporting browsers and prevents every folder-save operation from completing. Call the method directly as window.showDirectoryPicker() or bind it to window before calling.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output-delivery.ts around line 27:
`writeOutputsToFolder` extracts `window.showDirectoryPicker` into a standalone variable and then invokes it without the `window` receiver, which throws `TypeError: Illegal invocation` in supporting browsers and prevents every folder-save operation from completing. Call the method directly as `window.showDirectoryPicker()` or bind it to `window` before calling.

Comment threadsrc/styles.css
overflow: hidden;
}

.difference-preview__source {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumsrc/styles.css:42

The source image inside .astryx-overlay-scrim is sized to width: 100% of that wrapper, but the wrapper's width is reduced to var(--difference-reveal). At a 50% split the source image is squeezed into a half-width box and scaled by object-fit: contain instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching .difference-preview) rather than 100% of the scrim.

Also found in 1 other location(s)

src/features/comparison/image-comparison.tsx:79

The slider comparison places the source image inside .astryx-overlay-scrim, while the stylesheet changes that wrapper's width to --difference-reveal and gives its image width: 100%. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/styles.css around line 42:
The source image inside `.astryx-overlay-scrim` is sized to `width: 100%` of that wrapper, but the wrapper's width is reduced to `var(--difference-reveal)`. At a 50% split the source image is squeezed into a half-width box and scaled by `object-fit: contain` instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching `.difference-preview`) rather than `100%` of the scrim.
Also found in 1 other location(s):
- src/features/comparison/image-comparison.tsx:79 -- The slider comparison places the source image inside `.astryx-overlay-scrim`, while the stylesheet changes that wrapper's width to `--difference-reveal` and gives its image `width: 100%`. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@devchaudhary24k
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: add local image processing workflow canvas - #1

Draft
devchaudhary24k wants to merge 7 commits into
mainfrom
dev
Draft

feat: add local image processing workflow canvas#1
devchaudhary24k wants to merge 7 commits into
mainfrom
dev

Conversation

@devchaudhary24k

@devchaudhary24kdevchaudhary24k commented Aug 4, 2026

Copy link
Copy Markdown
Member

What changed

  • adds validated JPEG/PNG input with allocation limits
  • processes resize and WebP encoding in a dedicated browser Web Worker
  • adds cancellation, structured progress/errors, comparison, and download
  • adds the six-node editable React Flow starter pipeline
  • adds connection validation, inspector settings, reconnect/delete/restore, undo/redo, and keyboard controls
  • disables PostHog autocapture and session recording for local file privacy

Code-quality and correctness

  • colocates tests in feature-owned __tests__/ directories
  • separates workflow graph constants, types, validation, canvas rendering, toolbar, inspector, and model code
  • separates image and processing limits, domain types, and validators
  • centralizes byte formatting and adds an HTTP-compatible random-ID fallback
  • invalidates stale output when resize or quality settings change
  • rejects truncated or malformed PNG IHDR headers before decode
  • keeps connection preview and drop validation consistent without breaking reconnection
  • validates worker requests before creating active jobs
  • rejects synchronous worker-transfer failures through the shared cleanup path
  • documents the code-organization rules in AGENTS.md

Why native Web Workers

The image pipeline is intentionally browser-local. BullMQ requires a Node.js service and Redis, which would upload or remotely process files and violate the current local-first scope.

Validation

  • pnpm validate
  • 16 tests across 6 focused test files
  • TypeScript and Biome pass
  • production client, SSR, and Nitro builds pass

Validation limitation

A browser automation smoke check could not run on this machine because no Chromium binary is installed.

Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/image-input/validators.ts
Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
}: CreateRecipeInput): Recipe {
const timestamp = new Date().toISOString()

return parseRecipe({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/recipe.ts:26

createRecipe calls parseRecipe synchronously, so when renameTemplate is invalid (e.g. empty or too long), it throws from the click handler before persist(...) is ever called. Because the save UI only attaches .catch() to the persist(...) promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting createRecipe inside the async save flow so its validation error is caught by the same .catch() handler, or validate renameTemplate before calling createRecipe.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/recipe.ts around line 26:
`createRecipe` calls `parseRecipe` synchronously, so when `renameTemplate` is invalid (e.g. empty or too long), it throws from the click handler before `persist(...)` is ever called. Because the save UI only attaches `.catch()` to the `persist(...)` promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting `createRecipe` inside the async save flow so its validation error is caught by the same `.catch()` handler, or validate `renameTemplate` before calling `createRecipe`.

return typeof window !== 'undefined' && 'showDirectoryPicker' in window
}

export async function pickFolderImages() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumimage-input/folder.ts:26

pickFolderImages traverses the entire directory tree and calls handle.getFile() for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a File object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into collectFiles so it stops walking the tree once enough images are collected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/image-input/folder.ts around line 26:
`pickFolderImages` traverses the entire directory tree and calls `handle.getFile()` for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a `File` object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into `collectFiles` so it stops walking the tree once enough images are collected.

})
}

async function withStore<T>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/local-store.ts:42

withStore resolves when the individual IDBRequest succeeds, not when the surrounding transaction commits. A readwrite transaction can still abort after put/delete succeeds, but saveRecipe, savePreferences, and saveRun will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its complete event before resolving; reject on abort or error.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/local-store.ts around line 42:
`withStore` resolves when the individual `IDBRequest` succeeds, not when the surrounding transaction commits. A `readwrite` transaction can still abort after `put`/`delete` succeeds, but `saveRecipe`, `savePreferences`, and `saveRun` will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its `complete` event before resolving; reject on `abort` or `error`.

const PrivacyContext = createContext<PrivacyContextValue | null>(null)

export function PrivacyProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprivacy/privacy-provider.tsx:17

If a user changes a privacy setting before loadPreferences resolves, the stored value overwrites their newer choice. update calls setPreferences synchronously, but the useEffect callback later calls setPreferences(stored) unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

 export function PrivacyProvider({ children }: { children: ReactNode }) {
- const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const userModifiedRef = useRef(false)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/privacy/privacy-provider.tsx around line 17:
If a user changes a privacy setting before `loadPreferences` resolves, the stored value overwrites their newer choice. `update` calls `setPreferences` synchronously, but the `useEffect` callback later calls `setPreferences(stored)` unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

let outputName = `${prefix}${stem}.${extension}`
let collision = 2

while (used.has(outputName.toLocaleLowerCase())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumprocessing/output.ts:39

createOutputNames uses toLocaleLowerCase() for collision detection, so in Turkish locales I.png and i.png normalize to different keys (ı and i). Both files keep colliding output names instead of the second getting a -2 suffix, defeating the dedup logic. Use toLowerCase() instead so normalization is locale-independent.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output.ts around line 39:
`createOutputNames` uses `toLocaleLowerCase()` for collision detection, so in Turkish locales `I.png` and `i.png` normalize to different keys (`ı` and `i`). Both files keep colliding output names instead of the second getting a `-2` suffix, defeating the dedup logic. Use `toLowerCase()` instead so normalization is locale-independent.

Comment on lines +332 to +355
try {
for (const { file, relativePath } of files.slice(0, MAX_BATCH_FILES)) {
try {
const info = inspectImageHeader(await file.arrayBuffer(), file.type)
if (selectionId !== selectionIdRef.current) return
valid.push({
id: createId(),
file,
info,
progress: 0,
relativePath,
status: 'ready',
})
} catch (reason) {
const message =
reason instanceof ImageValidationError
? reason.message
: 'The image could not be inspected.'
rejected.push(`${relativePath}: ${message}`)
}
}

setBatchItems(valid)
const first = valid[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcanvas/canvas-workbench.tsx:332

In inspectFiles, when a newer file selection supersedes an older one while the older selection's file.arrayBuffer() or inspectImageHeader call is still pending, a validation failure in the older call triggers its catch block which pushes to rejected and then falls through to setBatchItems(valid), setError, and setFailedNodeId — overwriting the newer selection's state with stale data. The selectionId guard is only checked inside the try after successful validation, not in the catch block or before the final setBatchItems/setError calls. Consider rechecking selectionId in the catch block and before committing final state.

- } catch (reason) {- const message =- reason instanceof ImageValidationError- ? reason.message- : 'The image could not be inspected.'- rejected.push(`${relativePath}: ${message}`)- }+ } catch (reason) {+ if (selectionId !== selectionIdRef.current) return+ const message =+ reason instanceof ImageValidationError+ ? reason.message+ : 'The image could not be inspected.'+ rejected.push(`${relativePath}: ${message}`)+ }
}
- setBatchItems(valid)+ if (selectionId !== selectionIdRef.current) return+ setBatchItems(valid)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 332-355:
In `inspectFiles`, when a newer file selection supersedes an older one while the older selection's `file.arrayBuffer()` or `inspectImageHeader` call is still pending, a validation failure in the older call triggers its `catch` block which pushes to `rejected` and then falls through to `setBatchItems(valid)`, `setError`, and `setFailedNodeId` — overwriting the newer selection's state with stale data. The `selectionId` guard is only checked inside the `try` after successful validation, not in the `catch` block or before the final `setBatchItems`/`setError` calls. Consider rechecking `selectionId` in the `catch` block and before committing final state.

Comment on lines +942 to +951
recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
setGraphIssue('Restore and reconnect missing nodes before running.')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highcanvas/canvas-workbench.tsx:942

deleteSelected mutates the graph but never invalidates the completed run state, so runState stays complete and the Run button stays hidden even though the modified workflow needs to be rerun. The old result also continues to appear valid for a graph it no longer matches. Connect/reconnect, addMissingNode, applyRecipe, undo, and redo have the same stale-output problem — they all mutate the graph without calling invalidateResult(). Consider calling invalidateResult() (or at least resetting runState) after every graph mutation.

 recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
+ invalidateResult()
setGraphIssue('Restore and reconnect missing nodes before running.')
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 942-951:
`deleteSelected` mutates the graph but never invalidates the completed run state, so `runState` stays `complete` and the Run button stays hidden even though the modified workflow needs to be rerun. The old `result` also continues to appear valid for a graph it no longer matches. Connect/reconnect, `addMissingNode`, `applyRecipe`, `undo`, and `redo` have the same stale-output problem — they all mutate the graph without calling `invalidateResult()`. Consider calling `invalidateResult()` (or at least resetting `runState`) after every graph mutation.

Comment on lines +83 to +88
<Button
label="Use"
variant="secondary"
size="sm"
onClick={() => onUseTournamentResult(entry.format)}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcomparison/image-lab.tsx:83

The Use button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls onUseTournamentResult, which sets the shared run state to complete and re-enables processing actions while runTournament is still processing later codecs. This causes conflicting progress and run-state updates. The Use buttons in the tournament list should be disabled when isBusy is true.

 <Button
label="Use"
variant="secondary"
size="sm"
- onClick={() => onUseTournamentResult(entry.format)}+ isDisabled={isBusy}+ onClick={() => onUseTournamentResult(entry.format)}
/>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/comparison/image-lab.tsx around lines 83-88:
The `Use` button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls `onUseTournamentResult`, which sets the shared run state to `complete` and re-enables processing actions while `runTournament` is still processing later codecs. This causes conflicting progress and run-state updates. The `Use` buttons in the tournament list should be disabled when `isBusy` is true.

}

export async function writeOutputsToFolder(outputs: OutputFile[]) {
const picker = (window as FolderPickerWindow).showDirectoryPicker

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprocessing/output-delivery.ts:27

writeOutputsToFolder extracts window.showDirectoryPicker into a standalone variable and then invokes it without the window receiver, which throws TypeError: Illegal invocation in supporting browsers and prevents every folder-save operation from completing. Call the method directly as window.showDirectoryPicker() or bind it to window before calling.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output-delivery.ts around line 27:
`writeOutputsToFolder` extracts `window.showDirectoryPicker` into a standalone variable and then invokes it without the `window` receiver, which throws `TypeError: Illegal invocation` in supporting browsers and prevents every folder-save operation from completing. Call the method directly as `window.showDirectoryPicker()` or bind it to `window` before calling.

Comment threadsrc/styles.css
overflow: hidden;
}

.difference-preview__source {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumsrc/styles.css:42

The source image inside .astryx-overlay-scrim is sized to width: 100% of that wrapper, but the wrapper's width is reduced to var(--difference-reveal). At a 50% split the source image is squeezed into a half-width box and scaled by object-fit: contain instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching .difference-preview) rather than 100% of the scrim.

Also found in 1 other location(s)

src/features/comparison/image-comparison.tsx:79

The slider comparison places the source image inside .astryx-overlay-scrim, while the stylesheet changes that wrapper's width to --difference-reveal and gives its image width: 100%. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/styles.css around line 42:
The source image inside `.astryx-overlay-scrim` is sized to `width: 100%` of that wrapper, but the wrapper's width is reduced to `var(--difference-reveal)`. At a 50% split the source image is squeezed into a half-width box and scaled by `object-fit: contain` instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching `.difference-preview`) rather than `100%` of the scrim.
Also found in 1 other location(s):
- src/features/comparison/image-comparison.tsx:79 -- The slider comparison places the source image inside `.astryx-overlay-scrim`, while the stylesheet changes that wrapper's width to `--difference-reveal` and gives its image `width: 100%`. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@devchaudhary24k
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: add local image processing workflow canvas - #1

Draft
devchaudhary24k wants to merge 7 commits into
mainfrom
dev
Draft

feat: add local image processing workflow canvas#1
devchaudhary24k wants to merge 7 commits into
mainfrom
dev

Conversation

@devchaudhary24k

@devchaudhary24kdevchaudhary24k commented Aug 4, 2026

Copy link
Copy Markdown
Member

What changed

  • adds validated JPEG/PNG input with allocation limits
  • processes resize and WebP encoding in a dedicated browser Web Worker
  • adds cancellation, structured progress/errors, comparison, and download
  • adds the six-node editable React Flow starter pipeline
  • adds connection validation, inspector settings, reconnect/delete/restore, undo/redo, and keyboard controls
  • disables PostHog autocapture and session recording for local file privacy

Code-quality and correctness

  • colocates tests in feature-owned __tests__/ directories
  • separates workflow graph constants, types, validation, canvas rendering, toolbar, inspector, and model code
  • separates image and processing limits, domain types, and validators
  • centralizes byte formatting and adds an HTTP-compatible random-ID fallback
  • invalidates stale output when resize or quality settings change
  • rejects truncated or malformed PNG IHDR headers before decode
  • keeps connection preview and drop validation consistent without breaking reconnection
  • validates worker requests before creating active jobs
  • rejects synchronous worker-transfer failures through the shared cleanup path
  • documents the code-organization rules in AGENTS.md

Why native Web Workers

The image pipeline is intentionally browser-local. BullMQ requires a Node.js service and Redis, which would upload or remotely process files and violate the current local-first scope.

Validation

  • pnpm validate
  • 16 tests across 6 focused test files
  • TypeScript and Biome pass
  • production client, SSR, and Nitro builds pass

Validation limitation

A browser automation smoke check could not run on this machine because no Chromium binary is installed.

Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/image-input/validators.ts
Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
}: CreateRecipeInput): Recipe {
const timestamp = new Date().toISOString()

return parseRecipe({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/recipe.ts:26

createRecipe calls parseRecipe synchronously, so when renameTemplate is invalid (e.g. empty or too long), it throws from the click handler before persist(...) is ever called. Because the save UI only attaches .catch() to the persist(...) promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting createRecipe inside the async save flow so its validation error is caught by the same .catch() handler, or validate renameTemplate before calling createRecipe.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/recipe.ts around line 26:
`createRecipe` calls `parseRecipe` synchronously, so when `renameTemplate` is invalid (e.g. empty or too long), it throws from the click handler before `persist(...)` is ever called. Because the save UI only attaches `.catch()` to the `persist(...)` promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting `createRecipe` inside the async save flow so its validation error is caught by the same `.catch()` handler, or validate `renameTemplate` before calling `createRecipe`.

return typeof window !== 'undefined' && 'showDirectoryPicker' in window
}

export async function pickFolderImages() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumimage-input/folder.ts:26

pickFolderImages traverses the entire directory tree and calls handle.getFile() for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a File object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into collectFiles so it stops walking the tree once enough images are collected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/image-input/folder.ts around line 26:
`pickFolderImages` traverses the entire directory tree and calls `handle.getFile()` for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a `File` object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into `collectFiles` so it stops walking the tree once enough images are collected.

})
}

async function withStore<T>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/local-store.ts:42

withStore resolves when the individual IDBRequest succeeds, not when the surrounding transaction commits. A readwrite transaction can still abort after put/delete succeeds, but saveRecipe, savePreferences, and saveRun will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its complete event before resolving; reject on abort or error.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/local-store.ts around line 42:
`withStore` resolves when the individual `IDBRequest` succeeds, not when the surrounding transaction commits. A `readwrite` transaction can still abort after `put`/`delete` succeeds, but `saveRecipe`, `savePreferences`, and `saveRun` will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its `complete` event before resolving; reject on `abort` or `error`.

const PrivacyContext = createContext<PrivacyContextValue | null>(null)

export function PrivacyProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprivacy/privacy-provider.tsx:17

If a user changes a privacy setting before loadPreferences resolves, the stored value overwrites their newer choice. update calls setPreferences synchronously, but the useEffect callback later calls setPreferences(stored) unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

 export function PrivacyProvider({ children }: { children: ReactNode }) {
- const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const userModifiedRef = useRef(false)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/privacy/privacy-provider.tsx around line 17:
If a user changes a privacy setting before `loadPreferences` resolves, the stored value overwrites their newer choice. `update` calls `setPreferences` synchronously, but the `useEffect` callback later calls `setPreferences(stored)` unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

let outputName = `${prefix}${stem}.${extension}`
let collision = 2

while (used.has(outputName.toLocaleLowerCase())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumprocessing/output.ts:39

createOutputNames uses toLocaleLowerCase() for collision detection, so in Turkish locales I.png and i.png normalize to different keys (ı and i). Both files keep colliding output names instead of the second getting a -2 suffix, defeating the dedup logic. Use toLowerCase() instead so normalization is locale-independent.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output.ts around line 39:
`createOutputNames` uses `toLocaleLowerCase()` for collision detection, so in Turkish locales `I.png` and `i.png` normalize to different keys (`ı` and `i`). Both files keep colliding output names instead of the second getting a `-2` suffix, defeating the dedup logic. Use `toLowerCase()` instead so normalization is locale-independent.

Comment on lines +332 to +355
try {
for (const { file, relativePath } of files.slice(0, MAX_BATCH_FILES)) {
try {
const info = inspectImageHeader(await file.arrayBuffer(), file.type)
if (selectionId !== selectionIdRef.current) return
valid.push({
id: createId(),
file,
info,
progress: 0,
relativePath,
status: 'ready',
})
} catch (reason) {
const message =
reason instanceof ImageValidationError
? reason.message
: 'The image could not be inspected.'
rejected.push(`${relativePath}: ${message}`)
}
}

setBatchItems(valid)
const first = valid[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcanvas/canvas-workbench.tsx:332

In inspectFiles, when a newer file selection supersedes an older one while the older selection's file.arrayBuffer() or inspectImageHeader call is still pending, a validation failure in the older call triggers its catch block which pushes to rejected and then falls through to setBatchItems(valid), setError, and setFailedNodeId — overwriting the newer selection's state with stale data. The selectionId guard is only checked inside the try after successful validation, not in the catch block or before the final setBatchItems/setError calls. Consider rechecking selectionId in the catch block and before committing final state.

- } catch (reason) {- const message =- reason instanceof ImageValidationError- ? reason.message- : 'The image could not be inspected.'- rejected.push(`${relativePath}: ${message}`)- }+ } catch (reason) {+ if (selectionId !== selectionIdRef.current) return+ const message =+ reason instanceof ImageValidationError+ ? reason.message+ : 'The image could not be inspected.'+ rejected.push(`${relativePath}: ${message}`)+ }
}
- setBatchItems(valid)+ if (selectionId !== selectionIdRef.current) return+ setBatchItems(valid)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 332-355:
In `inspectFiles`, when a newer file selection supersedes an older one while the older selection's `file.arrayBuffer()` or `inspectImageHeader` call is still pending, a validation failure in the older call triggers its `catch` block which pushes to `rejected` and then falls through to `setBatchItems(valid)`, `setError`, and `setFailedNodeId` — overwriting the newer selection's state with stale data. The `selectionId` guard is only checked inside the `try` after successful validation, not in the `catch` block or before the final `setBatchItems`/`setError` calls. Consider rechecking `selectionId` in the `catch` block and before committing final state.

Comment on lines +942 to +951
recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
setGraphIssue('Restore and reconnect missing nodes before running.')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highcanvas/canvas-workbench.tsx:942

deleteSelected mutates the graph but never invalidates the completed run state, so runState stays complete and the Run button stays hidden even though the modified workflow needs to be rerun. The old result also continues to appear valid for a graph it no longer matches. Connect/reconnect, addMissingNode, applyRecipe, undo, and redo have the same stale-output problem — they all mutate the graph without calling invalidateResult(). Consider calling invalidateResult() (or at least resetting runState) after every graph mutation.

 recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
+ invalidateResult()
setGraphIssue('Restore and reconnect missing nodes before running.')
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 942-951:
`deleteSelected` mutates the graph but never invalidates the completed run state, so `runState` stays `complete` and the Run button stays hidden even though the modified workflow needs to be rerun. The old `result` also continues to appear valid for a graph it no longer matches. Connect/reconnect, `addMissingNode`, `applyRecipe`, `undo`, and `redo` have the same stale-output problem — they all mutate the graph without calling `invalidateResult()`. Consider calling `invalidateResult()` (or at least resetting `runState`) after every graph mutation.

Comment on lines +83 to +88
<Button
label="Use"
variant="secondary"
size="sm"
onClick={() => onUseTournamentResult(entry.format)}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcomparison/image-lab.tsx:83

The Use button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls onUseTournamentResult, which sets the shared run state to complete and re-enables processing actions while runTournament is still processing later codecs. This causes conflicting progress and run-state updates. The Use buttons in the tournament list should be disabled when isBusy is true.

 <Button
label="Use"
variant="secondary"
size="sm"
- onClick={() => onUseTournamentResult(entry.format)}+ isDisabled={isBusy}+ onClick={() => onUseTournamentResult(entry.format)}
/>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/comparison/image-lab.tsx around lines 83-88:
The `Use` button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls `onUseTournamentResult`, which sets the shared run state to `complete` and re-enables processing actions while `runTournament` is still processing later codecs. This causes conflicting progress and run-state updates. The `Use` buttons in the tournament list should be disabled when `isBusy` is true.

}

export async function writeOutputsToFolder(outputs: OutputFile[]) {
const picker = (window as FolderPickerWindow).showDirectoryPicker

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprocessing/output-delivery.ts:27

writeOutputsToFolder extracts window.showDirectoryPicker into a standalone variable and then invokes it without the window receiver, which throws TypeError: Illegal invocation in supporting browsers and prevents every folder-save operation from completing. Call the method directly as window.showDirectoryPicker() or bind it to window before calling.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output-delivery.ts around line 27:
`writeOutputsToFolder` extracts `window.showDirectoryPicker` into a standalone variable and then invokes it without the `window` receiver, which throws `TypeError: Illegal invocation` in supporting browsers and prevents every folder-save operation from completing. Call the method directly as `window.showDirectoryPicker()` or bind it to `window` before calling.

Comment threadsrc/styles.css
overflow: hidden;
}

.difference-preview__source {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumsrc/styles.css:42

The source image inside .astryx-overlay-scrim is sized to width: 100% of that wrapper, but the wrapper's width is reduced to var(--difference-reveal). At a 50% split the source image is squeezed into a half-width box and scaled by object-fit: contain instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching .difference-preview) rather than 100% of the scrim.

Also found in 1 other location(s)

src/features/comparison/image-comparison.tsx:79

The slider comparison places the source image inside .astryx-overlay-scrim, while the stylesheet changes that wrapper's width to --difference-reveal and gives its image width: 100%. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/styles.css around line 42:
The source image inside `.astryx-overlay-scrim` is sized to `width: 100%` of that wrapper, but the wrapper's width is reduced to `var(--difference-reveal)`. At a 50% split the source image is squeezed into a half-width box and scaled by `object-fit: contain` instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching `.difference-preview`) rather than `100%` of the scrim.
Also found in 1 other location(s):
- src/features/comparison/image-comparison.tsx:79 -- The slider comparison places the source image inside `.astryx-overlay-scrim`, while the stylesheet changes that wrapper's width to `--difference-reveal` and gives its image `width: 100%`. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@devchaudhary24k
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: add local image processing workflow canvas - #1

Draft
devchaudhary24k wants to merge 7 commits into
mainfrom
dev
Draft

feat: add local image processing workflow canvas#1
devchaudhary24k wants to merge 7 commits into
mainfrom
dev

Conversation

@devchaudhary24k

@devchaudhary24kdevchaudhary24k commented Aug 4, 2026

Copy link
Copy Markdown
Member

What changed

  • adds validated JPEG/PNG input with allocation limits
  • processes resize and WebP encoding in a dedicated browser Web Worker
  • adds cancellation, structured progress/errors, comparison, and download
  • adds the six-node editable React Flow starter pipeline
  • adds connection validation, inspector settings, reconnect/delete/restore, undo/redo, and keyboard controls
  • disables PostHog autocapture and session recording for local file privacy

Code-quality and correctness

  • colocates tests in feature-owned __tests__/ directories
  • separates workflow graph constants, types, validation, canvas rendering, toolbar, inspector, and model code
  • separates image and processing limits, domain types, and validators
  • centralizes byte formatting and adds an HTTP-compatible random-ID fallback
  • invalidates stale output when resize or quality settings change
  • rejects truncated or malformed PNG IHDR headers before decode
  • keeps connection preview and drop validation consistent without breaking reconnection
  • validates worker requests before creating active jobs
  • rejects synchronous worker-transfer failures through the shared cleanup path
  • documents the code-organization rules in AGENTS.md

Why native Web Workers

The image pipeline is intentionally browser-local. BullMQ requires a Node.js service and Redis, which would upload or remotely process files and violate the current local-first scope.

Validation

  • pnpm validate
  • 16 tests across 6 focused test files
  • TypeScript and Biome pass
  • production client, SSR, and Nitro builds pass

Validation limitation

A browser automation smoke check could not run on this machine because no Chromium binary is installed.

Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/image-input/validators.ts
Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
}: CreateRecipeInput): Recipe {
const timestamp = new Date().toISOString()

return parseRecipe({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/recipe.ts:26

createRecipe calls parseRecipe synchronously, so when renameTemplate is invalid (e.g. empty or too long), it throws from the click handler before persist(...) is ever called. Because the save UI only attaches .catch() to the persist(...) promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting createRecipe inside the async save flow so its validation error is caught by the same .catch() handler, or validate renameTemplate before calling createRecipe.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/recipe.ts around line 26:
`createRecipe` calls `parseRecipe` synchronously, so when `renameTemplate` is invalid (e.g. empty or too long), it throws from the click handler before `persist(...)` is ever called. Because the save UI only attaches `.catch()` to the `persist(...)` promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting `createRecipe` inside the async save flow so its validation error is caught by the same `.catch()` handler, or validate `renameTemplate` before calling `createRecipe`.

return typeof window !== 'undefined' && 'showDirectoryPicker' in window
}

export async function pickFolderImages() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumimage-input/folder.ts:26

pickFolderImages traverses the entire directory tree and calls handle.getFile() for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a File object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into collectFiles so it stops walking the tree once enough images are collected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/image-input/folder.ts around line 26:
`pickFolderImages` traverses the entire directory tree and calls `handle.getFile()` for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a `File` object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into `collectFiles` so it stops walking the tree once enough images are collected.

})
}

async function withStore<T>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/local-store.ts:42

withStore resolves when the individual IDBRequest succeeds, not when the surrounding transaction commits. A readwrite transaction can still abort after put/delete succeeds, but saveRecipe, savePreferences, and saveRun will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its complete event before resolving; reject on abort or error.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/local-store.ts around line 42:
`withStore` resolves when the individual `IDBRequest` succeeds, not when the surrounding transaction commits. A `readwrite` transaction can still abort after `put`/`delete` succeeds, but `saveRecipe`, `savePreferences`, and `saveRun` will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its `complete` event before resolving; reject on `abort` or `error`.

const PrivacyContext = createContext<PrivacyContextValue | null>(null)

export function PrivacyProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprivacy/privacy-provider.tsx:17

If a user changes a privacy setting before loadPreferences resolves, the stored value overwrites their newer choice. update calls setPreferences synchronously, but the useEffect callback later calls setPreferences(stored) unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

 export function PrivacyProvider({ children }: { children: ReactNode }) {
- const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const userModifiedRef = useRef(false)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/privacy/privacy-provider.tsx around line 17:
If a user changes a privacy setting before `loadPreferences` resolves, the stored value overwrites their newer choice. `update` calls `setPreferences` synchronously, but the `useEffect` callback later calls `setPreferences(stored)` unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

let outputName = `${prefix}${stem}.${extension}`
let collision = 2

while (used.has(outputName.toLocaleLowerCase())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumprocessing/output.ts:39

createOutputNames uses toLocaleLowerCase() for collision detection, so in Turkish locales I.png and i.png normalize to different keys (ı and i). Both files keep colliding output names instead of the second getting a -2 suffix, defeating the dedup logic. Use toLowerCase() instead so normalization is locale-independent.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output.ts around line 39:
`createOutputNames` uses `toLocaleLowerCase()` for collision detection, so in Turkish locales `I.png` and `i.png` normalize to different keys (`ı` and `i`). Both files keep colliding output names instead of the second getting a `-2` suffix, defeating the dedup logic. Use `toLowerCase()` instead so normalization is locale-independent.

Comment on lines +332 to +355
try {
for (const { file, relativePath } of files.slice(0, MAX_BATCH_FILES)) {
try {
const info = inspectImageHeader(await file.arrayBuffer(), file.type)
if (selectionId !== selectionIdRef.current) return
valid.push({
id: createId(),
file,
info,
progress: 0,
relativePath,
status: 'ready',
})
} catch (reason) {
const message =
reason instanceof ImageValidationError
? reason.message
: 'The image could not be inspected.'
rejected.push(`${relativePath}: ${message}`)
}
}

setBatchItems(valid)
const first = valid[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcanvas/canvas-workbench.tsx:332

In inspectFiles, when a newer file selection supersedes an older one while the older selection's file.arrayBuffer() or inspectImageHeader call is still pending, a validation failure in the older call triggers its catch block which pushes to rejected and then falls through to setBatchItems(valid), setError, and setFailedNodeId — overwriting the newer selection's state with stale data. The selectionId guard is only checked inside the try after successful validation, not in the catch block or before the final setBatchItems/setError calls. Consider rechecking selectionId in the catch block and before committing final state.

- } catch (reason) {- const message =- reason instanceof ImageValidationError- ? reason.message- : 'The image could not be inspected.'- rejected.push(`${relativePath}: ${message}`)- }+ } catch (reason) {+ if (selectionId !== selectionIdRef.current) return+ const message =+ reason instanceof ImageValidationError+ ? reason.message+ : 'The image could not be inspected.'+ rejected.push(`${relativePath}: ${message}`)+ }
}
- setBatchItems(valid)+ if (selectionId !== selectionIdRef.current) return+ setBatchItems(valid)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 332-355:
In `inspectFiles`, when a newer file selection supersedes an older one while the older selection's `file.arrayBuffer()` or `inspectImageHeader` call is still pending, a validation failure in the older call triggers its `catch` block which pushes to `rejected` and then falls through to `setBatchItems(valid)`, `setError`, and `setFailedNodeId` — overwriting the newer selection's state with stale data. The `selectionId` guard is only checked inside the `try` after successful validation, not in the `catch` block or before the final `setBatchItems`/`setError` calls. Consider rechecking `selectionId` in the `catch` block and before committing final state.

Comment on lines +942 to +951
recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
setGraphIssue('Restore and reconnect missing nodes before running.')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highcanvas/canvas-workbench.tsx:942

deleteSelected mutates the graph but never invalidates the completed run state, so runState stays complete and the Run button stays hidden even though the modified workflow needs to be rerun. The old result also continues to appear valid for a graph it no longer matches. Connect/reconnect, addMissingNode, applyRecipe, undo, and redo have the same stale-output problem — they all mutate the graph without calling invalidateResult(). Consider calling invalidateResult() (or at least resetting runState) after every graph mutation.

 recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
+ invalidateResult()
setGraphIssue('Restore and reconnect missing nodes before running.')
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 942-951:
`deleteSelected` mutates the graph but never invalidates the completed run state, so `runState` stays `complete` and the Run button stays hidden even though the modified workflow needs to be rerun. The old `result` also continues to appear valid for a graph it no longer matches. Connect/reconnect, `addMissingNode`, `applyRecipe`, `undo`, and `redo` have the same stale-output problem — they all mutate the graph without calling `invalidateResult()`. Consider calling `invalidateResult()` (or at least resetting `runState`) after every graph mutation.

Comment on lines +83 to +88
<Button
label="Use"
variant="secondary"
size="sm"
onClick={() => onUseTournamentResult(entry.format)}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcomparison/image-lab.tsx:83

The Use button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls onUseTournamentResult, which sets the shared run state to complete and re-enables processing actions while runTournament is still processing later codecs. This causes conflicting progress and run-state updates. The Use buttons in the tournament list should be disabled when isBusy is true.

 <Button
label="Use"
variant="secondary"
size="sm"
- onClick={() => onUseTournamentResult(entry.format)}+ isDisabled={isBusy}+ onClick={() => onUseTournamentResult(entry.format)}
/>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/comparison/image-lab.tsx around lines 83-88:
The `Use` button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls `onUseTournamentResult`, which sets the shared run state to `complete` and re-enables processing actions while `runTournament` is still processing later codecs. This causes conflicting progress and run-state updates. The `Use` buttons in the tournament list should be disabled when `isBusy` is true.

}

export async function writeOutputsToFolder(outputs: OutputFile[]) {
const picker = (window as FolderPickerWindow).showDirectoryPicker

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprocessing/output-delivery.ts:27

writeOutputsToFolder extracts window.showDirectoryPicker into a standalone variable and then invokes it without the window receiver, which throws TypeError: Illegal invocation in supporting browsers and prevents every folder-save operation from completing. Call the method directly as window.showDirectoryPicker() or bind it to window before calling.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output-delivery.ts around line 27:
`writeOutputsToFolder` extracts `window.showDirectoryPicker` into a standalone variable and then invokes it without the `window` receiver, which throws `TypeError: Illegal invocation` in supporting browsers and prevents every folder-save operation from completing. Call the method directly as `window.showDirectoryPicker()` or bind it to `window` before calling.

Comment threadsrc/styles.css
overflow: hidden;
}

.difference-preview__source {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumsrc/styles.css:42

The source image inside .astryx-overlay-scrim is sized to width: 100% of that wrapper, but the wrapper's width is reduced to var(--difference-reveal). At a 50% split the source image is squeezed into a half-width box and scaled by object-fit: contain instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching .difference-preview) rather than 100% of the scrim.

Also found in 1 other location(s)

src/features/comparison/image-comparison.tsx:79

The slider comparison places the source image inside .astryx-overlay-scrim, while the stylesheet changes that wrapper's width to --difference-reveal and gives its image width: 100%. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/styles.css around line 42:
The source image inside `.astryx-overlay-scrim` is sized to `width: 100%` of that wrapper, but the wrapper's width is reduced to `var(--difference-reveal)`. At a 50% split the source image is squeezed into a half-width box and scaled by `object-fit: contain` instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching `.difference-preview`) rather than `100%` of the scrim.
Also found in 1 other location(s):
- src/features/comparison/image-comparison.tsx:79 -- The slider comparison places the source image inside `.astryx-overlay-scrim`, while the stylesheet changes that wrapper's width to `--difference-reveal` and gives its image `width: 100%`. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@devchaudhary24k
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: add local image processing workflow canvas - #1

Draft
devchaudhary24k wants to merge 7 commits into
mainfrom
dev
Draft

feat: add local image processing workflow canvas#1
devchaudhary24k wants to merge 7 commits into
mainfrom
dev

Conversation

@devchaudhary24k

@devchaudhary24kdevchaudhary24k commented Aug 4, 2026

Copy link
Copy Markdown
Member

What changed

  • adds validated JPEG/PNG input with allocation limits
  • processes resize and WebP encoding in a dedicated browser Web Worker
  • adds cancellation, structured progress/errors, comparison, and download
  • adds the six-node editable React Flow starter pipeline
  • adds connection validation, inspector settings, reconnect/delete/restore, undo/redo, and keyboard controls
  • disables PostHog autocapture and session recording for local file privacy

Code-quality and correctness

  • colocates tests in feature-owned __tests__/ directories
  • separates workflow graph constants, types, validation, canvas rendering, toolbar, inspector, and model code
  • separates image and processing limits, domain types, and validators
  • centralizes byte formatting and adds an HTTP-compatible random-ID fallback
  • invalidates stale output when resize or quality settings change
  • rejects truncated or malformed PNG IHDR headers before decode
  • keeps connection preview and drop validation consistent without breaking reconnection
  • validates worker requests before creating active jobs
  • rejects synchronous worker-transfer failures through the shared cleanup path
  • documents the code-organization rules in AGENTS.md

Why native Web Workers

The image pipeline is intentionally browser-local. BullMQ requires a Node.js service and Redis, which would upload or remotely process files and violate the current local-first scope.

Validation

  • pnpm validate
  • 16 tests across 6 focused test files
  • TypeScript and Biome pass
  • production client, SSR, and Nitro builds pass

Validation limitation

A browser automation smoke check could not run on this machine because no Chromium binary is installed.

Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/image-input/validators.ts
Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
}: CreateRecipeInput): Recipe {
const timestamp = new Date().toISOString()

return parseRecipe({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/recipe.ts:26

createRecipe calls parseRecipe synchronously, so when renameTemplate is invalid (e.g. empty or too long), it throws from the click handler before persist(...) is ever called. Because the save UI only attaches .catch() to the persist(...) promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting createRecipe inside the async save flow so its validation error is caught by the same .catch() handler, or validate renameTemplate before calling createRecipe.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/recipe.ts around line 26:
`createRecipe` calls `parseRecipe` synchronously, so when `renameTemplate` is invalid (e.g. empty or too long), it throws from the click handler before `persist(...)` is ever called. Because the save UI only attaches `.catch()` to the `persist(...)` promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting `createRecipe` inside the async save flow so its validation error is caught by the same `.catch()` handler, or validate `renameTemplate` before calling `createRecipe`.

return typeof window !== 'undefined' && 'showDirectoryPicker' in window
}

export async function pickFolderImages() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumimage-input/folder.ts:26

pickFolderImages traverses the entire directory tree and calls handle.getFile() for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a File object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into collectFiles so it stops walking the tree once enough images are collected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/image-input/folder.ts around line 26:
`pickFolderImages` traverses the entire directory tree and calls `handle.getFile()` for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a `File` object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into `collectFiles` so it stops walking the tree once enough images are collected.

})
}

async function withStore<T>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/local-store.ts:42

withStore resolves when the individual IDBRequest succeeds, not when the surrounding transaction commits. A readwrite transaction can still abort after put/delete succeeds, but saveRecipe, savePreferences, and saveRun will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its complete event before resolving; reject on abort or error.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/local-store.ts around line 42:
`withStore` resolves when the individual `IDBRequest` succeeds, not when the surrounding transaction commits. A `readwrite` transaction can still abort after `put`/`delete` succeeds, but `saveRecipe`, `savePreferences`, and `saveRun` will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its `complete` event before resolving; reject on `abort` or `error`.

const PrivacyContext = createContext<PrivacyContextValue | null>(null)

export function PrivacyProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprivacy/privacy-provider.tsx:17

If a user changes a privacy setting before loadPreferences resolves, the stored value overwrites their newer choice. update calls setPreferences synchronously, but the useEffect callback later calls setPreferences(stored) unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

 export function PrivacyProvider({ children }: { children: ReactNode }) {
- const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const userModifiedRef = useRef(false)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/privacy/privacy-provider.tsx around line 17:
If a user changes a privacy setting before `loadPreferences` resolves, the stored value overwrites their newer choice. `update` calls `setPreferences` synchronously, but the `useEffect` callback later calls `setPreferences(stored)` unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

let outputName = `${prefix}${stem}.${extension}`
let collision = 2

while (used.has(outputName.toLocaleLowerCase())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumprocessing/output.ts:39

createOutputNames uses toLocaleLowerCase() for collision detection, so in Turkish locales I.png and i.png normalize to different keys (ı and i). Both files keep colliding output names instead of the second getting a -2 suffix, defeating the dedup logic. Use toLowerCase() instead so normalization is locale-independent.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output.ts around line 39:
`createOutputNames` uses `toLocaleLowerCase()` for collision detection, so in Turkish locales `I.png` and `i.png` normalize to different keys (`ı` and `i`). Both files keep colliding output names instead of the second getting a `-2` suffix, defeating the dedup logic. Use `toLowerCase()` instead so normalization is locale-independent.

Comment on lines +332 to +355
try {
for (const { file, relativePath } of files.slice(0, MAX_BATCH_FILES)) {
try {
const info = inspectImageHeader(await file.arrayBuffer(), file.type)
if (selectionId !== selectionIdRef.current) return
valid.push({
id: createId(),
file,
info,
progress: 0,
relativePath,
status: 'ready',
})
} catch (reason) {
const message =
reason instanceof ImageValidationError
? reason.message
: 'The image could not be inspected.'
rejected.push(`${relativePath}: ${message}`)
}
}

setBatchItems(valid)
const first = valid[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcanvas/canvas-workbench.tsx:332

In inspectFiles, when a newer file selection supersedes an older one while the older selection's file.arrayBuffer() or inspectImageHeader call is still pending, a validation failure in the older call triggers its catch block which pushes to rejected and then falls through to setBatchItems(valid), setError, and setFailedNodeId — overwriting the newer selection's state with stale data. The selectionId guard is only checked inside the try after successful validation, not in the catch block or before the final setBatchItems/setError calls. Consider rechecking selectionId in the catch block and before committing final state.

- } catch (reason) {- const message =- reason instanceof ImageValidationError- ? reason.message- : 'The image could not be inspected.'- rejected.push(`${relativePath}: ${message}`)- }+ } catch (reason) {+ if (selectionId !== selectionIdRef.current) return+ const message =+ reason instanceof ImageValidationError+ ? reason.message+ : 'The image could not be inspected.'+ rejected.push(`${relativePath}: ${message}`)+ }
}
- setBatchItems(valid)+ if (selectionId !== selectionIdRef.current) return+ setBatchItems(valid)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 332-355:
In `inspectFiles`, when a newer file selection supersedes an older one while the older selection's `file.arrayBuffer()` or `inspectImageHeader` call is still pending, a validation failure in the older call triggers its `catch` block which pushes to `rejected` and then falls through to `setBatchItems(valid)`, `setError`, and `setFailedNodeId` — overwriting the newer selection's state with stale data. The `selectionId` guard is only checked inside the `try` after successful validation, not in the `catch` block or before the final `setBatchItems`/`setError` calls. Consider rechecking `selectionId` in the `catch` block and before committing final state.

Comment on lines +942 to +951
recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
setGraphIssue('Restore and reconnect missing nodes before running.')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highcanvas/canvas-workbench.tsx:942

deleteSelected mutates the graph but never invalidates the completed run state, so runState stays complete and the Run button stays hidden even though the modified workflow needs to be rerun. The old result also continues to appear valid for a graph it no longer matches. Connect/reconnect, addMissingNode, applyRecipe, undo, and redo have the same stale-output problem — they all mutate the graph without calling invalidateResult(). Consider calling invalidateResult() (or at least resetting runState) after every graph mutation.

 recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
+ invalidateResult()
setGraphIssue('Restore and reconnect missing nodes before running.')
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 942-951:
`deleteSelected` mutates the graph but never invalidates the completed run state, so `runState` stays `complete` and the Run button stays hidden even though the modified workflow needs to be rerun. The old `result` also continues to appear valid for a graph it no longer matches. Connect/reconnect, `addMissingNode`, `applyRecipe`, `undo`, and `redo` have the same stale-output problem — they all mutate the graph without calling `invalidateResult()`. Consider calling `invalidateResult()` (or at least resetting `runState`) after every graph mutation.

Comment on lines +83 to +88
<Button
label="Use"
variant="secondary"
size="sm"
onClick={() => onUseTournamentResult(entry.format)}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcomparison/image-lab.tsx:83

The Use button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls onUseTournamentResult, which sets the shared run state to complete and re-enables processing actions while runTournament is still processing later codecs. This causes conflicting progress and run-state updates. The Use buttons in the tournament list should be disabled when isBusy is true.

 <Button
label="Use"
variant="secondary"
size="sm"
- onClick={() => onUseTournamentResult(entry.format)}+ isDisabled={isBusy}+ onClick={() => onUseTournamentResult(entry.format)}
/>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/comparison/image-lab.tsx around lines 83-88:
The `Use` button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls `onUseTournamentResult`, which sets the shared run state to `complete` and re-enables processing actions while `runTournament` is still processing later codecs. This causes conflicting progress and run-state updates. The `Use` buttons in the tournament list should be disabled when `isBusy` is true.

}

export async function writeOutputsToFolder(outputs: OutputFile[]) {
const picker = (window as FolderPickerWindow).showDirectoryPicker

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprocessing/output-delivery.ts:27

writeOutputsToFolder extracts window.showDirectoryPicker into a standalone variable and then invokes it without the window receiver, which throws TypeError: Illegal invocation in supporting browsers and prevents every folder-save operation from completing. Call the method directly as window.showDirectoryPicker() or bind it to window before calling.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output-delivery.ts around line 27:
`writeOutputsToFolder` extracts `window.showDirectoryPicker` into a standalone variable and then invokes it without the `window` receiver, which throws `TypeError: Illegal invocation` in supporting browsers and prevents every folder-save operation from completing. Call the method directly as `window.showDirectoryPicker()` or bind it to `window` before calling.

Comment threadsrc/styles.css
overflow: hidden;
}

.difference-preview__source {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumsrc/styles.css:42

The source image inside .astryx-overlay-scrim is sized to width: 100% of that wrapper, but the wrapper's width is reduced to var(--difference-reveal). At a 50% split the source image is squeezed into a half-width box and scaled by object-fit: contain instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching .difference-preview) rather than 100% of the scrim.

Also found in 1 other location(s)

src/features/comparison/image-comparison.tsx:79

The slider comparison places the source image inside .astryx-overlay-scrim, while the stylesheet changes that wrapper's width to --difference-reveal and gives its image width: 100%. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/styles.css around line 42:
The source image inside `.astryx-overlay-scrim` is sized to `width: 100%` of that wrapper, but the wrapper's width is reduced to `var(--difference-reveal)`. At a 50% split the source image is squeezed into a half-width box and scaled by `object-fit: contain` instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching `.difference-preview`) rather than `100%` of the scrim.
Also found in 1 other location(s):
- src/features/comparison/image-comparison.tsx:79 -- The slider comparison places the source image inside `.astryx-overlay-scrim`, while the stylesheet changes that wrapper's width to `--difference-reveal` and gives its image `width: 100%`. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@devchaudhary24k
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: add local image processing workflow canvas - #1

Draft
devchaudhary24k wants to merge 7 commits into
mainfrom
dev
Draft

feat: add local image processing workflow canvas#1
devchaudhary24k wants to merge 7 commits into
mainfrom
dev

Conversation

@devchaudhary24k

@devchaudhary24kdevchaudhary24k commented Aug 4, 2026

Copy link
Copy Markdown
Member

What changed

  • adds validated JPEG/PNG input with allocation limits
  • processes resize and WebP encoding in a dedicated browser Web Worker
  • adds cancellation, structured progress/errors, comparison, and download
  • adds the six-node editable React Flow starter pipeline
  • adds connection validation, inspector settings, reconnect/delete/restore, undo/redo, and keyboard controls
  • disables PostHog autocapture and session recording for local file privacy

Code-quality and correctness

  • colocates tests in feature-owned __tests__/ directories
  • separates workflow graph constants, types, validation, canvas rendering, toolbar, inspector, and model code
  • separates image and processing limits, domain types, and validators
  • centralizes byte formatting and adds an HTTP-compatible random-ID fallback
  • invalidates stale output when resize or quality settings change
  • rejects truncated or malformed PNG IHDR headers before decode
  • keeps connection preview and drop validation consistent without breaking reconnection
  • validates worker requests before creating active jobs
  • rejects synchronous worker-transfer failures through the shared cleanup path
  • documents the code-organization rules in AGENTS.md

Why native Web Workers

The image pipeline is intentionally browser-local. BullMQ requires a Node.js service and Redis, which would upload or remotely process files and violate the current local-first scope.

Validation

  • pnpm validate
  • 16 tests across 6 focused test files
  • TypeScript and Biome pass
  • production client, SSR, and Nitro builds pass

Validation limitation

A browser automation smoke check could not run on this machine because no Chromium binary is installed.

Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/image-input/validators.ts
Comment threadsrc/features/canvas/canvas-workbench.tsx Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
Comment threadsrc/features/processing/image-processor.ts Outdated
}: CreateRecipeInput): Recipe {
const timestamp = new Date().toISOString()

return parseRecipe({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/recipe.ts:26

createRecipe calls parseRecipe synchronously, so when renameTemplate is invalid (e.g. empty or too long), it throws from the click handler before persist(...) is ever called. Because the save UI only attaches .catch() to the persist(...) promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting createRecipe inside the async save flow so its validation error is caught by the same .catch() handler, or validate renameTemplate before calling createRecipe.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/recipe.ts around line 26:
`createRecipe` calls `parseRecipe` synchronously, so when `renameTemplate` is invalid (e.g. empty or too long), it throws from the click handler before `persist(...)` is ever called. Because the save UI only attaches `.catch()` to the `persist(...)` promise, that synchronous throw is unhandled and the workspace shows no validation message — the user clicks save and nothing visible happens. Consider awaiting `createRecipe` inside the async save flow so its validation error is caught by the same `.catch()` handler, or validate `renameTemplate` before calling `createRecipe`.

return typeof window !== 'undefined' && 'showDirectoryPicker' in window
}

export async function pickFolderImages() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumimage-input/folder.ts:26

pickFolderImages traverses the entire directory tree and calls handle.getFile() for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a File object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into collectFiles so it stops walking the tree once enough images are collected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/image-input/folder.ts around line 26:
`pickFolderImages` traverses the entire directory tree and calls `handle.getFile()` for every file before filtering to images and returning — there is no traversal or file-count limit. Selecting a large folder creates a `File` object for every file on disk (including non-images), which can stall folder selection and consume excessive memory. Consider passing a maximum file count into `collectFiles` so it stops walking the tree once enough images are collected.

})
}

async function withStore<T>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumrecipes/local-store.ts:42

withStore resolves when the individual IDBRequest succeeds, not when the surrounding transaction commits. A readwrite transaction can still abort after put/delete succeeds, but saveRecipe, savePreferences, and saveRun will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its complete event before resolving; reject on abort or error.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/recipes/local-store.ts around line 42:
`withStore` resolves when the individual `IDBRequest` succeeds, not when the surrounding transaction commits. A `readwrite` transaction can still abort after `put`/`delete` succeeds, but `saveRecipe`, `savePreferences`, and `saveRun` will already have observed success — so writes are silently lost and the UI shows "saved locally" even when nothing was stored. The transaction must reach its `complete` event before resolving; reject on `abort` or `error`.

const PrivacyContext = createContext<PrivacyContextValue | null>(null)

export function PrivacyProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprivacy/privacy-provider.tsx:17

If a user changes a privacy setting before loadPreferences resolves, the stored value overwrites their newer choice. update calls setPreferences synchronously, but the useEffect callback later calls setPreferences(stored) unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

 export function PrivacyProvider({ children }: { children: ReactNode }) {
- const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES)+ const userModifiedRef = useRef(false)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/privacy/privacy-provider.tsx around line 17:
If a user changes a privacy setting before `loadPreferences` resolves, the stored value overwrites their newer choice. `update` calls `setPreferences` synchronously, but the `useEffect` callback later calls `setPreferences(stored)` unconditionally, replacing the user's selection with the stale value from IndexedDB. Consider guarding the load with a ref that tracks whether the user has already modified preferences, and skip applying the stored value in that case.

let outputName = `${prefix}${stem}.${extension}`
let collision = 2

while (used.has(outputName.toLocaleLowerCase())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumprocessing/output.ts:39

createOutputNames uses toLocaleLowerCase() for collision detection, so in Turkish locales I.png and i.png normalize to different keys (ı and i). Both files keep colliding output names instead of the second getting a -2 suffix, defeating the dedup logic. Use toLowerCase() instead so normalization is locale-independent.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output.ts around line 39:
`createOutputNames` uses `toLocaleLowerCase()` for collision detection, so in Turkish locales `I.png` and `i.png` normalize to different keys (`ı` and `i`). Both files keep colliding output names instead of the second getting a `-2` suffix, defeating the dedup logic. Use `toLowerCase()` instead so normalization is locale-independent.

Comment on lines +332 to +355
try {
for (const { file, relativePath } of files.slice(0, MAX_BATCH_FILES)) {
try {
const info = inspectImageHeader(await file.arrayBuffer(), file.type)
if (selectionId !== selectionIdRef.current) return
valid.push({
id: createId(),
file,
info,
progress: 0,
relativePath,
status: 'ready',
})
} catch (reason) {
const message =
reason instanceof ImageValidationError
? reason.message
: 'The image could not be inspected.'
rejected.push(`${relativePath}: ${message}`)
}
}

setBatchItems(valid)
const first = valid[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcanvas/canvas-workbench.tsx:332

In inspectFiles, when a newer file selection supersedes an older one while the older selection's file.arrayBuffer() or inspectImageHeader call is still pending, a validation failure in the older call triggers its catch block which pushes to rejected and then falls through to setBatchItems(valid), setError, and setFailedNodeId — overwriting the newer selection's state with stale data. The selectionId guard is only checked inside the try after successful validation, not in the catch block or before the final setBatchItems/setError calls. Consider rechecking selectionId in the catch block and before committing final state.

- } catch (reason) {- const message =- reason instanceof ImageValidationError- ? reason.message- : 'The image could not be inspected.'- rejected.push(`${relativePath}: ${message}`)- }+ } catch (reason) {+ if (selectionId !== selectionIdRef.current) return+ const message =+ reason instanceof ImageValidationError+ ? reason.message+ : 'The image could not be inspected.'+ rejected.push(`${relativePath}: ${message}`)+ }
}
- setBatchItems(valid)+ if (selectionId !== selectionIdRef.current) return+ setBatchItems(valid)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 332-355:
In `inspectFiles`, when a newer file selection supersedes an older one while the older selection's `file.arrayBuffer()` or `inspectImageHeader` call is still pending, a validation failure in the older call triggers its `catch` block which pushes to `rejected` and then falls through to `setBatchItems(valid)`, `setError`, and `setFailedNodeId` — overwriting the newer selection's state with stale data. The `selectionId` guard is only checked inside the `try` after successful validation, not in the `catch` block or before the final `setBatchItems`/`setError` calls. Consider rechecking `selectionId` in the `catch` block and before committing final state.

Comment on lines +942 to +951
recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
setGraphIssue('Restore and reconnect missing nodes before running.')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highcanvas/canvas-workbench.tsx:942

deleteSelected mutates the graph but never invalidates the completed run state, so runState stays complete and the Run button stays hidden even though the modified workflow needs to be rerun. The old result also continues to appear valid for a graph it no longer matches. Connect/reconnect, addMissingNode, applyRecipe, undo, and redo have the same stale-output problem — they all mutate the graph without calling invalidateResult(). Consider calling invalidateResult() (or at least resetting runState) after every graph mutation.

 recordHistory()
setNodes((current) => current.filter((node) => !selectedNodeIds.has(node.id)))
setEdges((current) =>
current.filter(
(edge) =>
!edge.selected && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target),
),
)
+ invalidateResult()
setGraphIssue('Restore and reconnect missing nodes before running.')
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/canvas/canvas-workbench.tsx around lines 942-951:
`deleteSelected` mutates the graph but never invalidates the completed run state, so `runState` stays `complete` and the Run button stays hidden even though the modified workflow needs to be rerun. The old `result` also continues to appear valid for a graph it no longer matches. Connect/reconnect, `addMissingNode`, `applyRecipe`, `undo`, and `redo` have the same stale-output problem — they all mutate the graph without calling `invalidateResult()`. Consider calling `invalidateResult()` (or at least resetting `runState`) after every graph mutation.

Comment on lines +83 to +88
<Button
label="Use"
variant="secondary"
size="sm"
onClick={() => onUseTournamentResult(entry.format)}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumcomparison/image-lab.tsx:83

The Use button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls onUseTournamentResult, which sets the shared run state to complete and re-enables processing actions while runTournament is still processing later codecs. This causes conflicting progress and run-state updates. The Use buttons in the tournament list should be disabled when isBusy is true.

 <Button
label="Use"
variant="secondary"
size="sm"
- onClick={() => onUseTournamentResult(entry.format)}+ isDisabled={isBusy}+ onClick={() => onUseTournamentResult(entry.format)}
/>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/comparison/image-lab.tsx around lines 83-88:
The `Use` button on each tournament result is enabled while the tournament is still running, so clicking it mid-run calls `onUseTournamentResult`, which sets the shared run state to `complete` and re-enables processing actions while `runTournament` is still processing later codecs. This causes conflicting progress and run-state updates. The `Use` buttons in the tournament list should be disabled when `isBusy` is true.

}

export async function writeOutputsToFolder(outputs: OutputFile[]) {
const picker = (window as FolderPickerWindow).showDirectoryPicker

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Highprocessing/output-delivery.ts:27

writeOutputsToFolder extracts window.showDirectoryPicker into a standalone variable and then invokes it without the window receiver, which throws TypeError: Illegal invocation in supporting browsers and prevents every folder-save operation from completing. Call the method directly as window.showDirectoryPicker() or bind it to window before calling.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/features/processing/output-delivery.ts around line 27:
`writeOutputsToFolder` extracts `window.showDirectoryPicker` into a standalone variable and then invokes it without the `window` receiver, which throws `TypeError: Illegal invocation` in supporting browsers and prevents every folder-save operation from completing. Call the method directly as `window.showDirectoryPicker()` or bind it to `window` before calling.

Comment threadsrc/styles.css
overflow: hidden;
}

.difference-preview__source {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumsrc/styles.css:42

The source image inside .astryx-overlay-scrim is sized to width: 100% of that wrapper, but the wrapper's width is reduced to var(--difference-reveal). At a 50% split the source image is squeezed into a half-width box and scaled by object-fit: contain instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching .difference-preview) rather than 100% of the scrim.

Also found in 1 other location(s)

src/features/comparison/image-comparison.tsx:79

The slider comparison places the source image inside .astryx-overlay-scrim, while the stylesheet changes that wrapper's width to --difference-reveal and gives its image width: 100%. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/styles.css around line 42:
The source image inside `.astryx-overlay-scrim` is sized to `width: 100%` of that wrapper, but the wrapper's width is reduced to `var(--difference-reveal)`. At a 50% split the source image is squeezed into a half-width box and scaled by `object-fit: contain` instead of staying full-size and being clipped — so source pixels no longer align with the result image and the comparison is misleading. The image should retain the full viewport width while only the clipping container's width changes. Consider setting the image to a fixed full-preview width (e.g. matching `.difference-preview`) rather than `100%` of the scrim.
Also found in 1 other location(s):
- src/features/comparison/image-comparison.tsx:79 -- The slider comparison places the source image inside `.astryx-overlay-scrim`, while the stylesheet changes that wrapper's width to `--difference-reveal` and gives its image `width: 100%`. As the slider moves, the source image is resized into the shrinking wrapper rather than remaining full-size and being clipped, so it no longer aligns pixel-for-pixel with the result and the visual difference view is misleading. The source image needs to retain the full comparison viewport width while only its clipping container changes width.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@devchaudhary24k