{"schemaVersion":1,"id":"recommended.sm-captions","version":"1.0.0","name":"Captions workflow","description":"An editable production workflow using Captions v2.0.0.","stages":[{"id":"main","blockId":"sm.captions","version":"2.0.0","label":"Captions"}],"connections":[],"packages":[{"manifest":{"schemaVersion":1,"sdkVersion":"0.1.0","id":"sm.captions","version":"2.0.0","name":"Captions","description":"Turn a timed transcript or text into editable caption lines with word highlighting, then review and apply them to the timeline.","kind":"editor-extension","runtime":"javascript","entry":"src/run.js","license":"MIT","provenance":{"notice":"Original source (authorship declared by the packager)\nLicense: MIT\nChanges: Original implementation declared by the packager; no reused repository or dependency code.\nFiles: LICENSE, src/run.js\n\nMIT 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","portableSources":[{"kind":"original","license":"MIT","paths":["LICENSE","src/run.js"],"evidence":["upstream/0/LICENSE","upstream/0/src/run.js"],"changes":"Original implementation declared by the packager; no reused repository or dependency code."}]},"inputs":{"transcript":{"type":"transcript","primary":true,"required":false},"projectTranscript":{"type":"transcript","context":"transcript","required":false},"timeline":{"type":"timeline","context":"timeline"},"text":{"type":"text","default":"","description":"Leave blank to use the connected or project transcript; entered text uses estimated timing."},"start":{"type":"number","min":0,"max":86399,"default":0},"duration":{"type":"number","min":0,"max":86400,"default":0,"description":"Zero keeps transcript timing; otherwise captions fit inside this duration."},"highlightColor":{"type":"text","default":"Yellow"},"animation":{"type":"text","default":"fade"}},"outputs":{"edit":{"type":"timeline-edit","primary":true}},"permissions":{"project":["context.transcript.read","context.timeline.read","timeline.propose"],"network":[],"filesystem":[],"secrets":[]},"ui":[{"port":"text","control":"text"},{"port":"start","control":"number"},{"port":"duration","control":"number"},{"port":"highlightColor","control":"select","options":["Yellow","White","Amber"]},{"port":"animation","control":"select","options":["none","fade"]}]},"code":"// Generated from the Captions Block-owned implementation.\n// Block-owned caption construction. No application imports or I/O.\nconst clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));\n\n// Group timed words into short CapCut-style phrase lines (≤6 words / ≤36 chars /\n// sentence end / pause). Pure — mirrors the desktop autoCaptions chunker.\nfunction buildCaptionLines(timed) {\n  const MAX_WORDS = 6, MAX_CHARS = 36, GAP = 0.45;\n  const lines = []; let cur = [];\n  const flush = () => { if (cur.length) { lines.push(cur); cur = []; } };\n  for (let i = 0; i < timed.length; i++) {\n    const w = timed[i];\n    cur.push(w);\n    const joined = cur.map(x => x.text).join(' ');\n    const next = timed[i + 1];\n    const gap = next ? next.start - w.end : 0;\n    const endsSentence = /[.!?…]\"?$/.test(w.text);\n    if (cur.length >= MAX_WORDS || joined.length >= MAX_CHARS || endsSentence || (next && gap > GAP)) flush();\n  }\n  flush();\n  return lines;\n}\n\n// Build caption items (absolute timeline seconds, per-word karaoke timing) from a\n// transcribe result + the clip it came from. Pure + testable.\nfunction buildCaptionItems({ words = [], text = '' }, voClip, { highlightColor = '#ffd400', baseId = Date.now(), size = 32, background = '#000000', animation } = {}) {\n  const clipStart = Number(voClip?.start) || 0;\n  const clipDur = Number(voClip?.duration) || 0;\n  const clipEnd = clipDur > 0 ? clipStart + clipDur : Infinity;\n  let timed = (Array.isArray(words) ? words : []).filter(w => w && typeof w.text === 'string' && w.text.trim() && Number.isFinite(w.start) && Number.isFinite(w.end) && w.start >= 0 && w.end > w.start).slice().sort((a,b) => a.start-b.start);\n  if (!timed.length) {\n    const toks = String(text).split(/\\s+/).filter(Boolean);\n    const span = clipDur > 0 ? clipDur : Math.max(4, toks.length * 0.4);\n    timed = toks.map((tk, i) => ({ text: tk, start: (i / Math.max(1, toks.length)) * span, end: ((i + 1) / Math.max(1, toks.length)) * span }));\n  }\n  timed = timed.filter(w => clipDur <= 0 || w.start < clipDur);\n  if (!timed.length) return [];\n  return buildCaptionLines(timed).map((line, idx) => {\n    const start = clamp(clipStart + line[0].start, clipStart, clipEnd);\n    const rawEnd = clipStart + line[line.length - 1].end;\n    const end = Math.min(clipEnd, Math.max(rawEnd, start + 0.4));\n    const ws = line.map(w => ({ text: w.text, start: clamp(clipStart + w.start, start, end), end: clamp(clipStart + w.end, start, end) }));\n    return {\n      id: `caption-${baseId}-${idx}`,\n      text: line.map(w => w.text).join(' ').replace(/\\s+([,.!?…])/g, '$1'),\n      words: ws, highlightColor,\n      font: 'Hanken Grotesk', size, color: '#ffffff',\n      ...(animation === undefined ? {} : {animation}),\n      background, backgroundOpacity: 45, backgroundPadding: 10, backgroundRadius: 6,\n      position: 'bottom center', start, end,\n    };\n  });\n}\n\n\nfunction propose(input){\n const timeline=input.timeline,transcript=input.transcript||(input.text.trim()?{text:input.text}:input.projectTranscript)||{text:''};\n if(!timeline.activeTimelineId)throw Error('Open a saved timeline before creating captions.');\n const ids=new Set(['clips','audioItems','captions','textOverlays','stickers','effects'].flatMap(key=>(timeline[key]||[]).map(item=>item.id)));\n let n=1;while([...ids].some(id=>String(id).startsWith('caption-block-'+n+'-')))n++;\n const track=(timeline.tracks||[]).find(track=>track.type==='caption'&&!track.locked);\n if(!track&&(timeline.tracks||[]).some(track=>track.id==='captions'))throw Error('Unlock a caption track before adding captions.');\n const items=buildCaptionItems(transcript,{start:input.start,duration:input.duration},{baseId:'block-'+n,highlightColor:({Yellow:'#ffd400',White:'#ffffff',Amber:'#E8B23A'})[input.highlightColor],size:24,background:'none',animation:input.animation});\n if(!items.length)throw Error('Provide a timed transcript or enter caption text.');\n if(items.length>100)throw Error('This caption proposal supports up to 100 lines. Select a shorter transcript section.');\n return {edit:{schemaVersion:1,title:transcript.words?.length?'Add timed captions':'Add captions (estimated timing)',timelineId:timeline.activeTimelineId,commands:items.map(item=>({collection:'captions',operation:'caption.add',before:null,values:{...item,trackId:track?.id||'captions'}}))}};\n}\nreturn propose(input);\n","tests":[{"name":"Estimated timing from text","input":{"timeline":{"schemaVersion":1,"activeTimelineId":"main","clips":[],"audioItems":[],"captions":[],"tracks":[]},"text":"A new beginning","start":0,"duration":3,"highlightColor":"Yellow","animation":"fade"},"expected":{"edit":{"schemaVersion":1,"title":"Add captions (estimated timing)","timelineId":"main","commands":[{"collection":"captions","operation":"caption.add","before":null,"values":{"id":"caption-block-1-0","text":"A new beginning","words":[{"text":"A","start":0,"end":1},{"text":"new","start":1,"end":2},{"text":"beginning","start":2,"end":3}],"highlightColor":"#ffd400","font":"Hanken Grotesk","size":24,"color":"#ffffff","animation":"fade","background":"none","backgroundOpacity":45,"backgroundPadding":10,"backgroundRadius":6,"position":"bottom center","start":0,"end":3,"trackId":"captions"}}]}}},{"name":"Absolute word timing and phrase breaks","input":{"timeline":{"schemaVersion":1,"activeTimelineId":"main","clips":[],"audioItems":[],"captions":[],"tracks":[]},"text":"","start":10,"duration":2,"highlightColor":"Yellow","animation":"fade","transcript":{"schemaVersion":1,"words":[{"text":"Hello","start":0,"end":0.3},{"text":"world.","start":0.3,"end":0.8},{"text":"Again","start":1.5,"end":2}]}},"expected":{"edit":{"schemaVersion":1,"title":"Add timed captions","timelineId":"main","commands":[{"collection":"captions","operation":"caption.add","before":null,"values":{"id":"caption-block-1-0","text":"Hello world.","words":[{"text":"Hello","start":10,"end":10.3},{"text":"world.","start":10.3,"end":10.8}],"highlightColor":"#ffd400","font":"Hanken Grotesk","size":24,"color":"#ffffff","animation":"fade","background":"none","backgroundOpacity":45,"backgroundPadding":10,"backgroundRadius":6,"position":"bottom center","start":10,"end":10.8,"trackId":"captions"}},{"collection":"captions","operation":"caption.add","before":null,"values":{"id":"caption-block-1-1","text":"Again","words":[{"text":"Again","start":11.5,"end":12}],"highlightColor":"#ffd400","font":"Hanken Grotesk","size":24,"color":"#ffffff","animation":"fade","background":"none","backgroundOpacity":45,"backgroundPadding":10,"backgroundRadius":6,"position":"bottom center","start":11.5,"end":12,"trackId":"captions"}}]}}},{"name":"Preserve existing caption identities","input":{"timeline":{"schemaVersion":1,"activeTimelineId":"main","clips":[],"audioItems":[],"captions":[{"id":"caption-block-1-0","text":"Existing","start":0,"end":1}],"tracks":[]},"text":"Keep the existing caption","start":1,"duration":2,"highlightColor":"Yellow","animation":"fade"},"expected":{"edit":{"schemaVersion":1,"title":"Add captions (estimated timing)","timelineId":"main","commands":[{"collection":"captions","operation":"caption.add","before":null,"values":{"id":"caption-block-2-0","text":"Keep the existing caption","words":[{"text":"Keep","start":1,"end":1.5},{"text":"the","start":1.5,"end":2},{"text":"existing","start":2,"end":2.5},{"text":"caption","start":2.5,"end":3}],"highlightColor":"#ffd400","font":"Hanken Grotesk","size":24,"color":"#ffffff","animation":"fade","background":"none","backgroundOpacity":45,"backgroundPadding":10,"backgroundRadius":6,"position":"bottom center","start":1,"end":3,"trackId":"captions"}}]}}}],"portable":{"format":"stillmade-block/1","dependencies":[],"sources":[{"kind":"original","license":"MIT","paths":["LICENSE","src/run.js"],"evidence":["upstream/0/LICENSE","upstream/0/src/run.js"],"changes":"Original implementation declared by the packager; no reused repository or dependency code."}],"files":{"upstream/0/LICENSE":"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","upstream/0/src/run.js":"// Generated from the Captions Block-owned implementation.\n// Block-owned caption construction. No application imports or I/O.\nconst clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));\n\n// Group timed words into short CapCut-style phrase lines (≤6 words / ≤36 chars /\n// sentence end / pause). Pure — mirrors the desktop autoCaptions chunker.\nfunction buildCaptionLines(timed) {\n  const MAX_WORDS = 6, MAX_CHARS = 36, GAP = 0.45;\n  const lines = []; let cur = [];\n  const flush = () => { if (cur.length) { lines.push(cur); cur = []; } };\n  for (let i = 0; i < timed.length; i++) {\n    const w = timed[i];\n    cur.push(w);\n    const joined = cur.map(x => x.text).join(' ');\n    const next = timed[i + 1];\n    const gap = next ? next.start - w.end : 0;\n    const endsSentence = /[.!?…]\"?$/.test(w.text);\n    if (cur.length >= MAX_WORDS || joined.length >= MAX_CHARS || endsSentence || (next && gap > GAP)) flush();\n  }\n  flush();\n  return lines;\n}\n\n// Build caption items (absolute timeline seconds, per-word karaoke timing) from a\n// transcribe result + the clip it came from. Pure + testable.\nfunction buildCaptionItems({ words = [], text = '' }, voClip, { highlightColor = '#ffd400', baseId = Date.now(), size = 32, background = '#000000', animation } = {}) {\n  const clipStart = Number(voClip?.start) || 0;\n  const clipDur = Number(voClip?.duration) || 0;\n  const clipEnd = clipDur > 0 ? clipStart + clipDur : Infinity;\n  let timed = (Array.isArray(words) ? words : []).filter(w => w && typeof w.text === 'string' && w.text.trim() && Number.isFinite(w.start) && Number.isFinite(w.end) && w.start >= 0 && w.end > w.start).slice().sort((a,b) => a.start-b.start);\n  if (!timed.length) {\n    const toks = String(text).split(/\\s+/).filter(Boolean);\n    const span = clipDur > 0 ? clipDur : Math.max(4, toks.length * 0.4);\n    timed = toks.map((tk, i) => ({ text: tk, start: (i / Math.max(1, toks.length)) * span, end: ((i + 1) / Math.max(1, toks.length)) * span }));\n  }\n  timed = timed.filter(w => clipDur <= 0 || w.start < clipDur);\n  if (!timed.length) return [];\n  return buildCaptionLines(timed).map((line, idx) => {\n    const start = clamp(clipStart + line[0].start, clipStart, clipEnd);\n    const rawEnd = clipStart + line[line.length - 1].end;\n    const end = Math.min(clipEnd, Math.max(rawEnd, start + 0.4));\n    const ws = line.map(w => ({ text: w.text, start: clamp(clipStart + w.start, start, end), end: clamp(clipStart + w.end, start, end) }));\n    return {\n      id: `caption-${baseId}-${idx}`,\n      text: line.map(w => w.text).join(' ').replace(/\\s+([,.!?…])/g, '$1'),\n      words: ws, highlightColor,\n      font: 'Hanken Grotesk', size, color: '#ffffff',\n      ...(animation === undefined ? {} : {animation}),\n      background, backgroundOpacity: 45, backgroundPadding: 10, backgroundRadius: 6,\n      position: 'bottom center', start, end,\n    };\n  });\n}\n\n\nfunction propose(input){\n const timeline=input.timeline,transcript=input.transcript||(input.text.trim()?{text:input.text}:input.projectTranscript)||{text:''};\n if(!timeline.activeTimelineId)throw Error('Open a saved timeline before creating captions.');\n const ids=new Set(['clips','audioItems','captions','textOverlays','stickers','effects'].flatMap(key=>(timeline[key]||[]).map(item=>item.id)));\n let n=1;while([...ids].some(id=>String(id).startsWith('caption-block-'+n+'-')))n++;\n const track=(timeline.tracks||[]).find(track=>track.type==='caption'&&!track.locked);\n if(!track&&(timeline.tracks||[]).some(track=>track.id==='captions'))throw Error('Unlock a caption track before adding captions.');\n const items=buildCaptionItems(transcript,{start:input.start,duration:input.duration},{baseId:'block-'+n,highlightColor:({Yellow:'#ffd400',White:'#ffffff',Amber:'#E8B23A'})[input.highlightColor],size:24,background:'none',animation:input.animation});\n if(!items.length)throw Error('Provide a timed transcript or enter caption text.');\n if(items.length>100)throw Error('This caption proposal supports up to 100 lines. Select a shorter transcript section.');\n return {edit:{schemaVersion:1,title:transcript.words?.length?'Add timed captions':'Add captions (estimated timing)',timelineId:timeline.activeTimelineId,commands:items.map(item=>({collection:'captions',operation:'caption.add',before:null,values:{...item,trackId:track?.id||'captions'}}))}};\n}\nreturn propose(input);\n"},"lineage":{"parents":[],"changes":"Packaged original Block source for StillMade."}}}],"license":"MIT"}