{"manifest":{"schemaVersion":1,"sdkVersion":"0.1.0","id":"sm.video-assets","version":"2.1.2","name":"Video Assets","description":"Review current and historical project media, include imported or connected generation results, choose exact versions, and hand them to the next production step in order.","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 associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"},"inputs":{"assets":{"type":"asset[]","context":"assets","primary":true,"required":false,"default":[],"description":"Existing project media, retaining asset and version identities."},"versions":{"type":"metadata[]","context":"versions","required":false,"description":"Saved inactive and historical project media versions."},"incoming":{"type":"asset[]","required":false,"default":[],"description":"Imported media or exact outputs connected from generation Blocks."},"kind":{"type":"text","default":"All","description":"Limit the workspace to one media kind."},"selectedIds":{"type":"text","default":"","description":"Newline-separated asset IDs selected in this workspace; blank keeps every matching asset."},"selectedVersions":{"type":"text","default":"","description":"Newline-separated assetId::versionId pairs in output order. This takes precedence over legacy asset-only selection."}},"outputs":{"output":{"type":"asset[]","primary":true,"role":"production_media","description":"Selected original media references, ready for another Block or the Editor."}},"permissions":{"project":["context.assets.read","context.versions.read","asset.read"],"network":[],"filesystem":[],"secrets":[]},"ui":[{"port":"kind","control":"select","options":["All","Images","Videos","Audio"]},{"port":"selectedIds","control":"text"},{"port":"selectedVersions","control":"text"}]},"code":"const current=input.assets||[],historic=(input.versions||[]).map(item=>item&&item.asset).filter(Boolean),incoming=input.incoming||[];\nconst assets=[...current,...historic,...incoming].filter((asset,index,list)=>asset&&list.findIndex(other=>other&&other.kind===asset.kind&&other.assetId===asset.assetId&&other.versionId===asset.versionId)===index);\nif (assets.length > 500) throw new Error('Video Assets accepts up to 500 project media versions at once.');\nconst kinds = {All: '', Images: 'image', Videos: 'video', Audio: 'audio'};\nif (!Object.hasOwn(kinds, input.kind)) throw new Error('Choose All, Images, Videos, or Audio.');\nconst available = assets.filter(asset => !kinds[input.kind] || asset.kind === kinds[input.kind]);\nconst keys = input.selectedVersions.split(/\\r?\\n/).map(value => value.trim()).filter(Boolean);\nif (keys.length > 500 || new Set(keys).size !== keys.length) throw new Error('Choose up to 500 distinct asset versions.');\nif (keys.length) {\n const selected = keys.map(key => {\n  const split = key.indexOf('::');\n  if (split < 1 || split === key.length - 2) throw new Error('Choose versions as assetId::versionId, one per line.');\n  const assetId = key.slice(0, split), versionId = key.slice(split + 2);\n  return available.find(asset => asset.assetId === assetId && asset.versionId === versionId);\n });\n if (selected.some(asset => !asset)) throw new Error('One or more selected media versions are unavailable in this project or filter.');\n return {output: selected};\n}\nconst ids = input.selectedIds.split(/\\r?\\n/).map(value => value.trim()).filter(Boolean);\nif (ids.length > 500 || new Set(ids).size !== ids.length) throw new Error('Choose up to 500 distinct asset IDs.');\nif (!ids.length) return {output: available};\nconst selected = available.filter(asset => ids.includes(asset.assetId));\nif (new Set(selected.map(asset => asset.assetId)).size !== ids.length) throw new Error('One or more selected assets are unavailable in this project or filter.');\nreturn {output: selected};\n","view":{"html":"<main class=\"assets-workspace\">\n  <header><h1>Video Assets</h1><p>Choose current, historical, imported, and connected generation versions to send forward.</p></header>\n  <div class=\"toolbar\">\n    <label for=\"kind\">Show</label>\n    <select id=\"kind\"><option>All</option><option>Images</option><option>Videos</option><option>Audio</option></select>\n    <button id=\"all\" type=\"button\">Select all</button>\n    <button id=\"none\" type=\"button\">Clear</button>\n  </div>\n  <section id=\"assets\" class=\"asset-list\" aria-label=\"Project media\"><p>No project media is available yet.</p></section>\n  <section class=\"selection-panel\" aria-label=\"Selected media order\"><h2>Selected order</h2><p>Move exact takes into the order the next Block should receive them.</p><div id=\"selection\" class=\"selection-list\"></div></section>\n  <div class=\"footer\"><button id=\"use\" data-stillmade-action=\"run\" type=\"button\">Use selected versions</button><output id=\"status\" role=\"status\" aria-live=\"polite\">Waiting for project media.</output></div>\n</main>\n","css":".assets-workspace { box-sizing: border-box; max-width: 960px; min-height: 100%; margin: 0 auto; padding: 24px; color: var(--fg); background: var(--bg); font-family: var(--font-body); }\n.assets-workspace h1 { margin: 0 0 8px; font-family: var(--font-display); font-size: 36px; font-style: italic; font-weight: 200; }\n.assets-workspace header p, .assets-workspace output { color: var(--fg-muted); }\n.toolbar, .footer { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 20px 0; }\n.toolbar select, .assets-workspace button { min-height: 44px; padding: 9px 14px; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--bg-elev-1); color: var(--fg); font: inherit; }\n.assets-workspace button { cursor: pointer; }\n.assets-workspace button:disabled { opacity: .5; cursor: default; }\n.asset-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; }\n.asset { display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: start; min-width: 0; padding: 14px; border: 1px solid var(--border-tok); border-radius: 12px; background: var(--bg-elev-1); }\n.asset input[type=\"checkbox\"] { width: 44px; height: 44px; margin: 0; }\n.asset span { min-width: 0; overflow-wrap: anywhere; }\n.asset small { display: block; margin-top: 4px; color: var(--fg-subtle); }\n.selection-panel { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--divider); }\n.selection-panel h2 { margin: 0 0 5px; font-size: 18px; }\n.selection-panel > p { margin: 0 0 12px; color: var(--fg-muted); }\n.selection-list { display: grid; gap: 8px; }\n.selected { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 8px; align-items: center; padding: 10px 12px; border: 1px solid var(--border-tok); border-radius: 12px; background: var(--bg-elev-1); }\n.selected span { min-width: 0; overflow-wrap: anywhere; }\n.selected small { display: block; margin-top: 3px; color: var(--fg-subtle); }\n.selected button { min-width: 44px; padding: 8px 10px; }\n.footer #use { background: var(--fg); color: var(--bg); }\n.assets-workspace button:focus-visible, .assets-workspace select:focus-visible, .asset input:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }\n@media (max-width: 640px) { .assets-workspace { padding: 16px; } .asset-list { grid-template-columns: minmax(0, 1fr); } .selected { grid-template-columns: minmax(0, 1fr) auto auto; } .selected button:last-child { grid-column: 1 / -1; } }\n","javascript":"const kind = document.getElementById('kind');\nconst all = document.getElementById('all');\nconst none = document.getElementById('none');\nconst use = document.getElementById('use');\nconst status = document.getElementById('status');\nlet current = {};\nlet assets = [];\nlet selectedKeys = [];\nlet busy = false;\nlet revision = 0;\n\nconst keyOf = asset => asset.assetId + '::' + asset.versionId;\nconst mediaKind = value => value === 'Images' ? 'image' : value === 'Videos' ? 'video' : value === 'Audio' ? 'audio' : '';\nconst visible = () => assets.filter(asset => !mediaKind(kind.value) || asset.kind === mediaKind(kind.value));\nconst titleOf = asset => asset.name || asset.label || asset.assetId;\nconst detailOf = asset => (asset.kind || 'asset') + ' · version ' + asset.versionId + (asset.sceneNumber ? ' · scene ' + String(asset.sceneNumber) : '') + (asset.shotId ? ' · shot ' + String(asset.shotId) : '');\nconst draw = () => {\n  const shown = visible();\n  selectedKeys = selectedKeys.filter(key => shown.some(asset => keyOf(asset) === key));\n  StillMade.render('assets', shown.length ? shown.map((asset, index) => ({\n    tag: 'label', className: 'asset', children: [\n      {tag: 'input', id: 'asset-' + index, type: 'checkbox', action: 'asset:' + index, checked: selectedKeys.includes(keyOf(asset)), disabled: busy, label: titleOf(asset)},\n      {tag: 'span', children: [{tag: 'strong', text: titleOf(asset)}, {tag: 'small', text: detailOf(asset)}]}\n    ]\n  })) : [{tag: 'p', text: 'No matching project media is available.'}]);\n  StillMade.render('selection', selectedKeys.length ? selectedKeys.map((key, index) => {\n    const asset = shown.find(item => keyOf(item) === key);\n    return {tag: 'article', className: 'selected', children: [\n      {tag: 'span', children: [{tag: 'strong', text: String(index + 1) + '. ' + titleOf(asset)}, {tag: 'small', text: detailOf(asset)}]},\n      {tag: 'button', action: 'up:' + index, text: '↑', disabled: busy || index === 0, label: 'Move earlier'},\n      {tag: 'button', action: 'down:' + index, text: '↓', disabled: busy || index === selectedKeys.length - 1, label: 'Move later'},\n      {tag: 'button', action: 'remove:' + index, text: 'Remove', disabled: busy}\n    ]};\n  }) : [{tag: 'p', text: 'No versions selected.'}]);\n  all.disabled = busy || !shown.length;\n  none.disabled = busy || !selectedKeys.length;\n  use.disabled = busy || !selectedKeys.length;\n  status.textContent = selectedKeys.length ? String(selectedKeys.length) + ' exact version' + (selectedKeys.length === 1 ? '' : 's') + ' selected in output order.' : shown.length ? 'Select media to continue.' : 'No matching media.';\n};\n\nStillMade.onInput(values => {\n  current = values;\n  revision += 1;\n  const historic=(values.versions||[]).map(item=>item&&item.asset).filter(Boolean);\n  assets=[...(values.assets||[]),...historic,...(values.incoming||[])].filter((asset,index,list)=>asset&&list.findIndex(other=>other&&other.kind===asset.kind&&other.assetId===asset.assetId&&other.versionId===asset.versionId)===index).slice(0,500);\n  kind.value = values.kind || 'All';\n  const exact = (values.selectedVersions || '').split(/\\r?\\n/).map(value => value.trim()).filter(key => assets.some(asset => keyOf(asset) === key));\n  const legacy = (values.selectedIds || '').split(/\\r?\\n/).map(value => value.trim()).filter(Boolean);\n  selectedKeys = exact.length ? exact : legacy.length ? assets.filter(asset => legacy.includes(asset.assetId)).map(keyOf) : visible().map(keyOf);\n  draw();\n});\nStillMade.onAction(event => {\n  if (busy) return;\n  const parts = event.action.split(':');\n  const action = parts[0], index = Number(parts[1]);\n  if (action === 'asset') {\n    const asset = visible().find((item, position) => position === index);\n    if (!asset) return;\n    const key = keyOf(asset);\n    selectedKeys = event.checked ? [...selectedKeys.filter(value => value !== key), key] : selectedKeys.filter(value => value !== key);\n  }\n  if (action === 'remove') selectedKeys = selectedKeys.filter((value, position) => position !== index);\n  if (action === 'up' && index > 0) { const item = selectedKeys.find((value, position) => position === index), before = selectedKeys.find((value, position) => position === index - 1); selectedKeys = selectedKeys.map((value, position) => position === index - 1 ? item : position === index ? before : value); }\n  if (action === 'down' && index < selectedKeys.length - 1) { const item = selectedKeys.find((value, position) => position === index), after = selectedKeys.find((value, position) => position === index + 1); selectedKeys = selectedKeys.map((value, position) => position === index ? after : position === index + 1 ? item : value); }\n  draw();\n});\nkind.addEventListener('change', () => { if (!busy) { selectedKeys = visible().map(keyOf); draw(); } });\nall.addEventListener('click', () => { if (!busy) { selectedKeys = visible().map(keyOf); draw(); } });\nnone.addEventListener('click', () => { if (!busy) { selectedKeys = []; draw(); } });\nuse.addEventListener('click', async () => {\n  if (busy || !selectedKeys.length) return;\n  const started = revision;\n  busy = true;\n  draw();\n  let message;\n  try {\n    const result = await StillMade.run({assets: current.assets || [], versions: current.versions || [], incoming: current.incoming || [], kind: kind.value, selectedIds: '', selectedVersions: selectedKeys.join('\\n')});\n    message = String((result.output || []).length) + ' exact media versions are ready for the next step.';\n  } catch (error) { message = error.message || 'The selection could not be prepared.'; }\n  finally { busy = false; draw(); if (started === revision) status.textContent = message; }\n});\n"},"tests":[{"name":"Keep every project media item by default","input":{"assets":[{"kind":"image","assetId":"image-1","versionId":"image-v1","url":"https://media.example/image.png","name":"Opening"},{"kind":"video","assetId":"video-1","versionId":"video-v2","url":"https://media.example/video.mp4","name":"Action"}],"kind":"All","selectedIds":""},"expected":{"output":[{"kind":"image","assetId":"image-1","versionId":"image-v1","url":"https://media.example/image.png","name":"Opening"},{"kind":"video","assetId":"video-1","versionId":"video-v2","url":"https://media.example/video.mp4","name":"Action"}]}},{"name":"Choose one original video version","input":{"assets":[{"kind":"image","assetId":"image-1","versionId":"image-v1","url":"https://media.example/image.png"},{"kind":"video","assetId":"video-1","versionId":"video-v2","url":"https://media.example/video.mp4"}],"kind":"Videos","selectedIds":"video-1"},"expected":{"output":[{"kind":"video","assetId":"video-1","versionId":"video-v2","url":"https://media.example/video.mp4"}]}},{"name":"Choose exact takes in editor order","input":{"assets":[{"kind":"video","assetId":"shot-1","versionId":"take-1","url":"https://media.example/shot-1-take-1.mp4","name":"Opening","sceneNumber":1,"shotId":"1a"},{"kind":"video","assetId":"shot-1","versionId":"take-2","url":"https://media.example/shot-1-take-2.mp4","name":"Opening","sceneNumber":1,"shotId":"1a"},{"kind":"video","assetId":"shot-2","versionId":"take-1","url":"https://media.example/shot-2-take-1.mp4","name":"Ending","sceneNumber":2,"shotId":"2a"},{"kind":"image","assetId":"frame-1","versionId":"still-1","url":"https://media.example/frame.png","name":"Still"}],"kind":"Videos","selectedIds":"","selectedVersions":"shot-2::take-1\nshot-1::take-2"},"expected":{"output":[{"kind":"video","assetId":"shot-2","versionId":"take-1","url":"https://media.example/shot-2-take-1.mp4","name":"Ending","sceneNumber":2,"shotId":"2a"},{"kind":"video","assetId":"shot-1","versionId":"take-2","url":"https://media.example/shot-1-take-2.mp4","name":"Opening","sceneNumber":1,"shotId":"1a"}]}}]}