{"schemaVersion":1,"id":"recommended.sm-compare","version":"1.0.0","name":"Compare assets workflow","description":"An editable production workflow using Compare assets v2.0.0.","stages":[{"id":"main","blockId":"sm.compare","version":"2.0.0","label":"Compare assets"}],"connections":[],"packages":[{"manifest":{"schemaVersion":1,"sdkVersion":"0.1.0","id":"sm.compare","version":"2.0.0","name":"Compare assets","description":"Compare two images with a slider and keep the original you prefer.","kind":"workspace","runtime":"javascript","entry":"src/run.js","license":"MIT","provenance":{"notice":"MIT License\n\nCopyright (c) 2026 StillMade contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and \nassociated documentation files (the \"Software\"), to deal in the Software without restriction, including \nwithout limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell \ncopies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the \nfollowing conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial \nportions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT \nLIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO \nEVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER \nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE \nUSE OR OTHER DEALINGS IN THE SOFTWARE.\n"},"inputs":{"imageA":{"type":"image","primary":true,"required":true,"imageMode":"reference"},"imageB":{"type":"image","required":true,"imageMode":"reference"},"selection":{"type":"text","default":"A"},"position":{"type":"integer","default":50,"min":0,"max":100}},"outputs":{"output":{"type":"image","primary":true}},"permissions":{"project":[],"network":[],"filesystem":[],"secrets":[]},"ui":[{"control":"asset","port":"imageA"},{"control":"asset","port":"imageB"},{"control":"select","port":"selection","options":["A","B"]},{"control":"slider","port":"position"}]},"code":"if (input.selection !== 'A' && input.selection !== 'B') throw new Error('Choose image A or B.');\nreturn { output: input.selection === 'A' ? input.imageA : input.imageB };\n","view":{"html":"<h1>Compare assets</h1>\n<p>Choose two images, slide to compare, then keep the original you prefer.</p>\n<canvas id=\"comparison\" width=\"512\" height=\"288\" aria-label=\"Image A on the left and image B on the right\"></canvas>\n<img id=\"image-a\" alt=\"Image A\" hidden>\n<img id=\"image-b\" alt=\"Image B\" hidden>\n<label for=\"position\">Comparison position</label>\n<input id=\"position\" type=\"range\" min=\"0\" max=\"100\" value=\"50\">\n<div class=\"choices\"><button id=\"keep-a\" data-stillmade-action=\"run\" disabled>Keep image A</button><button id=\"keep-b\" disabled>Keep image B</button></div>\n<output id=\"status\" aria-live=\"polite\">Choose both images to compare.</output>\n","css":"canvas { display: block; width: 100%; aspect-ratio: 16 / 9; background: var(--bg-inset); border: 1px solid var(--border-tok); border-radius: var(--r-3); }\ninput[type=range] { width: 100%; }\n.choices { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }\noutput { display: block; color: var(--fg-muted); }\n","javascript":"const canvas = document.getElementById('comparison');\nconst ctx = canvas.getContext('2d');\nconst imageA = document.getElementById('image-a');\nconst imageB = document.getElementById('image-b');\nconst position = document.getElementById('position');\nconst keepA = document.getElementById('keep-a');\nconst keepB = document.getElementById('keep-b');\nconst status = document.getElementById('status');\nlet currentInput = null;\nlet revision = 0;\nlet ready = false;\nlet pending = false;\nlet imageKey = '';\nlet previewsComplete = false;\nconst paint = () => {\n ctx.clearRect(0, 0, 512, 288);\n if (!currentInput) return;\n const split = Math.round(Number(position.value) * 512 / 100);\n if (imageA.complete && imageA.naturalWidth > 0) {\n  const scaleA = Math.max(512 / imageA.naturalWidth, 288 / imageA.naturalHeight);\n  ctx.save(); ctx.beginPath(); ctx.rect(0, 0, Math.max(0, split - 1), 288); ctx.clip();\n  ctx.drawImage(imageA, (512 - imageA.naturalWidth * scaleA) / 2, (288 - imageA.naturalHeight * scaleA) / 2, imageA.naturalWidth * scaleA, imageA.naturalHeight * scaleA); ctx.restore();\n }\n if (imageB.complete && imageB.naturalWidth > 0) {\n  const scaleB = Math.max(512 / imageB.naturalWidth, 288 / imageB.naturalHeight);\n  ctx.save(); ctx.beginPath(); ctx.rect(split, 0, 512 - split, 288); ctx.clip();\n  ctx.drawImage(imageB, (512 - imageB.naturalWidth * scaleB) / 2, (288 - imageB.naturalHeight * scaleB) / 2, imageB.naturalWidth * scaleB, imageB.naturalHeight * scaleB); ctx.restore();\n }\n};\nposition.addEventListener('input', paint);\nconst loaded = () => {\n ready = !!currentInput?.imageA && !!currentInput?.imageB && imageA.complete && imageA.naturalWidth > 0 && imageB.complete && imageB.naturalWidth > 0;\n keepA.disabled = !ready || pending; keepB.disabled = !ready || pending;\n status.textContent = ready ? 'Slide to compare. Your chosen image keeps its original quality.' : currentInput?.imageA && currentInput?.imageB ? 'Loading image previews…' : 'Choose both images to compare.'; paint();\n};\nimageA.addEventListener('load', loaded); imageB.addEventListener('load', loaded);\nStillMade.onInput(async values => {\n currentInput = values;\n position.value = String(values.position ?? 50);\n const nextKey = JSON.stringify([values.imageA, values.imageB]);\n if (imageKey === nextKey && previewsComplete) { paint(); return; }\n imageKey = nextKey; previewsComplete = false;\n const captured = ++revision;\n ready = false; keepA.disabled = true; keepB.disabled = true; paint();\n imageA.src = ''; imageB.src = '';\n if (!values.imageA && !values.imageB) { previewsComplete = true; status.textContent = 'Choose both images to compare.'; return; }\n status.textContent = 'Loading image previews…';\n try {\n  const previewA = values.imageA ? await StillMade.previewImage(values.imageA) : null;\n  if (captured !== revision) return;\n  if (previewA) imageA.src = previewA.url;\n  const previewB = values.imageB ? await StillMade.previewImage(values.imageB) : null;\n  if (captured !== revision) return;\n  if (previewB) imageB.src = previewB.url;\n  previewsComplete = true;\n } catch (error) { if (captured === revision) { imageKey = ''; status.textContent = error.message; } }\n});\nconst choose = async selection => {\n if (!ready || pending || !currentInput) return;\n const captured = revision;\n pending = true; keepA.disabled = true; keepB.disabled = true;\n status.textContent = 'Keeping image ' + selection + '…';\n try {\n  await StillMade.run({ imageA: currentInput.imageA, imageB: currentInput.imageB, selection, position: Number(position.value) });\n  if (captured === revision) status.textContent = 'Image ' + selection + ' is ready for the next step.';\n } catch (error) { if (captured === revision) status.textContent = error.message; }\n finally { pending = false; keepA.disabled = !ready; keepB.disabled = !ready; }\n};\nkeepA.addEventListener('click', () => choose('A'));\nkeepB.addEventListener('click', () => choose('B'));\n"},"tests":[{"name":"Preserves original A pixels and alpha","input":{"imageA":{"width":1,"height":1,"data":[10,20,30,127]},"imageB":{"width":1,"height":1,"data":[255,100,0,0]},"selection":"A"},"expected":{"output":{"width":1,"height":1,"data":[10,20,30,127]}}},{"name":"Preserves original B including transparent color channels","input":{"imageA":{"width":1,"height":1,"data":[10,20,30,127]},"imageB":{"width":1,"height":1,"data":[255,100,0,0]},"selection":"B"},"expected":{"output":{"width":1,"height":1,"data":[255,100,0,0]}}}]}],"license":"MIT"}