stillmade-sdk 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -93,9 +93,9 @@ export default {
93
93
  "code": "// The interface records, edits and cuts on the device (StillMade.desktop\n// record.start / record.stop / record.cut). This run only hands the chosen\n// video, and the uncut original, on as typed outputs.\nconst video = input.video;\nif (!video) throw new Error('Record a screen or choose a video first.');\nreturn { video: video, original: input.original || video };\n",
94
94
  "view": {
95
95
  "runtime": "frame",
96
- "html": "<main class=\"sm-block sr\" data-layout=\"stack\">\n <section id=\"ready\" class=\"sr-panel\">\n <p class=\"sr-lead\">Record a screen or window. The recording stays on this device until you pass it on.</p>\n <p class=\"sr-capacity\" id=\"capacity\" role=\"status\"></p>\n <div class=\"sr-options\">\n <label class=\"sr-check\"><input id=\"mic\" type=\"checkbox\" data-stillmade-share=\"microphone\"> Microphone</label>\n <label class=\"sr-check\"><input id=\"system\" type=\"checkbox\" data-stillmade-share=\"computerSound\"> Computer sound</label>\n <label class=\"sr-select\">Size <select id=\"resolution\" data-stillmade-share=\"resolution\"><option value=\"1080p\" selected>1080p</option><option value=\"source\">Full size</option></select></label>\n </div>\n <div class=\"sm-actions\">\n <button type=\"button\" class=\"sm-primary\" id=\"start\">Start recording</button>\n <p class=\"sm-status\" id=\"ready-status\" role=\"status\">Nothing is captured until you press Start in the StillMade controls above.</p>\n </div>\n </section>\n <section id=\"live\" class=\"sr-panel\" hidden>\n <div class=\"sr-live\">\n <span class=\"sr-dot\" aria-hidden=\"true\"></span>\n <strong id=\"timer\" class=\"sr-timer\">0:00.0</strong>\n <span id=\"size\" class=\"sr-size\">0 KB</span>\n </div>\n <div class=\"sm-actions\">\n <button type=\"button\" class=\"sm-primary\" id=\"stop\">Stop and edit</button>\n <button type=\"button\" class=\"sr-btn\" id=\"discard\">Discard</button>\n <p class=\"sm-status\" id=\"live-status\" role=\"status\">Recording. Press Stop and edit when you are done.</p>\n <p class=\"sr-capacity\" id=\"live-capacity\" role=\"status\"></p>\n </div>\n </section>\n <section id=\"editor\" class=\"sr-panel\" hidden>\n <video id=\"player\" class=\"sr-player\" playsinline preload=\"metadata\"></video>\n <div class=\"sr-transport\">\n <button type=\"button\" class=\"sr-btn\" id=\"play\" aria-label=\"Play\">Play</button>\n <span id=\"time\" class=\"sr-time\">0:00.0 / 0:00.0</span>\n <label class=\"sr-check sr-skip\"><input id=\"skip\" type=\"checkbox\" checked data-stillmade-local> Skip removed parts</label>\n </div>\n <div id=\"timeline\" class=\"sr-timeline\" role=\"group\" aria-label=\"Recording timeline\" tabindex=\"0\">\n <div id=\"segments\" class=\"sr-segments\"></div>\n <div id=\"playhead\" class=\"sr-playhead\" aria-hidden=\"true\"></div>\n </div>\n <div class=\"sr-tools\">\n <button type=\"button\" class=\"sr-btn\" id=\"cut\">Cut here</button>\n <button type=\"button\" class=\"sr-btn\" id=\"toggle\" disabled>Remove part</button>\n <button type=\"button\" class=\"sr-btn\" id=\"undo\" disabled>Undo</button>\n <span id=\"kept\" class=\"sr-kept\"></span>\n </div>\n <div class=\"sm-actions\">\n <button type=\"button\" class=\"sm-primary\" id=\"save\">Use the whole recording</button>\n <button type=\"button\" class=\"sr-btn\" id=\"again\">Record again</button>\n <p class=\"sm-status\" id=\"editor-status\" role=\"status\"></p>\n </div>\n </section>\n <section id=\"done\" class=\"sr-panel\" hidden>\n <p class=\"sr-lead\" id=\"done-text\">Saved.</p>\n <div class=\"sm-actions\">\n <button type=\"button\" class=\"sm-primary\" id=\"edit-again\">Edit again</button>\n <button type=\"button\" class=\"sr-btn\" id=\"record-again\">Record again</button>\n </div>\n </section>\n</main>\n",
97
- "css": ".sr { display: block; }\n.sr-panel { display: grid; gap: 1rem; padding: 1.25rem; }\n.sr-lead { margin: 0; color: var(--fg-muted); }\n.sr-capacity { margin: 0; color: var(--fg-muted); font-size: .92em; }\n.sr-capacity[data-warn=\"true\"] { color: var(--warning); }\n.sr-options { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }\n.sr-check { display: inline-flex; align-items: center; gap: .45rem; color: var(--fg); }\n.sr-select { display: inline-flex; align-items: center; gap: .45rem; color: var(--fg); }\n.sr-select select { border: 1px solid var(--border-tok); border-radius: var(--r-3); background: var(--bg-elev-1); color: var(--fg); padding: .35rem .5rem; }\n.sr-btn { border: 1px solid var(--border-tok); border-radius: 999px; padding: .6rem 1rem; background: var(--bg-elev-1); color: var(--fg); cursor: pointer; }\n.sr-btn:hover { background: var(--hover-wash); }\n.sr-btn:disabled, .sm-primary:disabled { opacity: .5; cursor: default; }\n.sr-live { display: flex; align-items: center; gap: .75rem; }\n.sr-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--danger); }\n.sr-timer { font-size: 1.6rem; font-variant-numeric: tabular-nums; }\n.sr-size { color: var(--fg-muted); font-variant-numeric: tabular-nums; }\n.sr-player { width: 100%; max-height: 52vh; background: var(--bg-inset); border-radius: var(--r-3); }\n.sr-transport { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }\n.sr-time { font-variant-numeric: tabular-nums; color: var(--fg-muted); }\n.sr-skip { margin-left: auto; color: var(--fg-muted); }\n.sr-timeline { position: relative; height: 56px; border-radius: var(--r-3); background: var(--bg-inset); border: 1px solid var(--border-tok); overflow: hidden; touch-action: none; user-select: none; }\n.sr-timeline:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n.sr-segments { position: absolute; inset: 0; }\n.sr-seg { position: absolute; top: 0; bottom: 0; border: 0; padding: 0; background: var(--accent-dim); cursor: pointer; }\n.sr-seg[data-kept=\"false\"] { background: var(--hover-wash); opacity: .55; }\n.sr-seg[aria-pressed=\"true\"] { box-shadow: inset 0 0 0 2px var(--accent); opacity: 1; }\n.sr-handle { position: absolute; top: 0; bottom: 0; width: 16px; margin-left: -8px; cursor: ew-resize; background: transparent; border: 0; padding: 0; }\n.sr-handle::after { content: \"\"; position: absolute; top: 6px; bottom: 6px; left: 6px; width: 4px; border-radius: 2px; background: var(--fg); opacity: .7; }\n.sr-handle:hover::after, .sr-handle[data-active=\"true\"]::after { background: var(--accent); opacity: 1; }\n.sr-playhead { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--fg); pointer-events: none; }\n.sr-tools { display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; }\n.sr-kept { margin-left: auto; color: var(--fg-muted); font-variant-numeric: tabular-nums; }\n@media (max-width: 480px) { .sr-panel { padding: 1rem; } .sr-skip { margin-left: 0; } }\n",
98
- "javascript": "(()=>{\n\"use strict\";\n// The cut editor's model: the recording is an ordered list of segments that\n// together cover it, each kept or removed. A cut splits the segment under the\n// playhead, removing a segment drops it from the result, and dragging a\n// boundary trims both neighbours. Every change returns a new array, so undo\n// keeps the earlier ones. Times are seconds of the source recording.\nconst MIN_SEGMENT = 0.1;\nlet nextSegmentId = 1;\nfunction createSegments(duration) {\n nextSegmentId = 1;\n return [{ id: nextSegmentId++, start: 0, end: duration, kept: true }];\n}\nfunction segmentAt(segments, time) {\n const found = segments.find(segment => time >= segment.start && time < segment.end);\n if (found) return found;\n const last = segments[segments.length - 1];\n return last && time >= last.end ? last : null;\n}\nfunction splitAt(segments, time) {\n const index = segments.findIndex(segment => time > segment.start + MIN_SEGMENT && time < segment.end - MIN_SEGMENT);\n if (index < 0) return segments;\n const segment = segments[index];\n return [...segments.slice(0, index), { ...segment, end: time }, { id: nextSegmentId++, start: time, end: segment.end, kept: segment.kept }, ...segments.slice(index + 1)];\n}\nfunction toggleKept(segments, id) {\n const index = segments.findIndex(segment => segment.id === id);\n if (index < 0) return segments;\n const next = segments.slice();\n next[index] = { ...next[index], kept: !next[index].kept };\n return next;\n}\n/** Moves the boundary before segments[index], clamped so both neighbours keep a minimum length. */\nfunction moveBoundary(segments, index, time) {\n if (index < 1 || index >= segments.length) return segments;\n const left = segments[index - 1], right = segments[index];\n const clamped = Math.min(right.end - MIN_SEGMENT, Math.max(left.start + MIN_SEGMENT, time));\n if (clamped === left.end) return segments;\n const next = segments.slice();\n next[index - 1] = { ...left, end: clamped };\n next[index] = { ...right, start: clamped };\n return next;\n}\n/** Trims the recording's start or end to `time`: the trimmed part becomes a removed segment. */\nfunction trimEdge(segments, edge, time) {\n if (edge === 'start') {\n if (!segments[0].kept) return moveBoundary(segments, 1, time);\n const split = splitAt(segments, time);\n return split === segments ? segments : toggleKept(split, split[0].id);\n }\n const last = segments[segments.length - 1];\n if (!last.kept) return moveBoundary(segments, segments.length - 1, time);\n const split = splitAt(segments, time);\n return split === segments ? segments : toggleKept(split, split[split.length - 1].id);\n}\n/** The kept ranges in order, neighbours merged: what record.cut keeps. */\nfunction keepRanges(segments) {\n const ranges = [];\n for (const segment of segments) {\n if (!segment.kept) continue;\n const last = ranges[ranges.length - 1];\n if (last && Math.abs(last.end - segment.start) < 1e-6) last.end = segment.end;\n else ranges.push({ start: segment.start, end: segment.end });\n }\n return ranges;\n}\nfunction keptDuration(segments) {\n return segments.reduce((sum, segment) => segment.kept ? sum + segment.end - segment.start : sum, 0);\n}\nfunction isWhole(segments) {\n return segments.every(segment => segment.kept);\n}\n/** Where playback continues from `time` when removed parts are skipped: `time` itself when kept, the next kept start, or null when nothing kept follows. */\nfunction nextKeptTime(segments, time) {\n const segment = segmentAt(segments, time);\n if (!segment || segment.kept) return time;\n const later = segments.find(candidate => candidate.kept && candidate.start >= segment.end);\n return later ? later.start : null;\n}\nfunction firstKeptTime(segments) {\n const segment = segments.find(candidate => candidate.kept);\n return segment ? segment.start : 0;\n}\n/** Seconds as m:ss.d */\nfunction formatTime(seconds) {\n const total = Math.max(0, Math.round((Number(seconds) || 0) * 10) / 10);\n const minutes = Math.floor(total / 60), whole = Math.floor(total % 60), tenth = Math.round((total - Math.floor(total)) * 10) % 10;\n return `${minutes}:${String(whole).padStart(2, '0')}.${tenth}`;\n}\nfunction formatBytes(bytes) {\n const value = Number(bytes) || 0;\n return value >= 1024 * 1024 * 1024 ? `${(value / 1024 / 1024 / 1024).toFixed(2)} GB` : value >= 1024 * 1024 ? `${Math.round(value / 1024 / 1024)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;\n}\n\n// The Screen Recorder interface: record (the host records, this view only asks),\n// then a small cut editor over the recording kept on this device, then one\n// run that hands the result on. Built into src/view.js with the cut model.\nconst $ = id => document.getElementById(id);\nconst panels = { ready: $('ready'), live: $('live'), editor: $('editor'), done: $('done') };\nconst player = $('player'), timeline = $('timeline'), segmentsNode = $('segments'), playhead = $('playhead');\nconst state = { phase: 'ready', source: null, segments: [], undo: [], selected: null, duration: 0, previewUrl: null, dragging: null, poll: null, busy: false, lastSaved: null, device: null };\n\nfunction show(phase) {\n state.phase = phase;\n for (const [name, node] of Object.entries(panels)) node.hidden = name !== phase;\n}\nconst say = (id, text) => { $(id).textContent = text || ''; };\nconst message = error => String(error && error.message || error || 'Something went wrong.');\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\nconst busy = value => { state.busy = value; for (const id of ['start', 'stop', 'discard', 'cut', 'toggle', 'undo', 'save', 'again', 'edit-again', 'record-again']) $(id).disabled = value || ($(id).dataset.off === 'true'); };\n\n// ── What fits here ─────────────────────────────────────────────────────────\nconst minutes = seconds => Math.max(1, Math.round(seconds / 60));\nfunction describeDevice(device) {\n if (!device) return '';\n if (!device.recorder) return 'This browser cannot record. Use a current desktop browser or StillMade Desktop.';\n const notes = [];\n if (device.platform === 'desktop') notes.push('Records straight to disk; up to ' + minutes(device.budget.seconds) + ' minutes per recording.');\n else if (device.budget.seconds < 60) notes.push('Less than a minute fits on this device. Free up storage or use StillMade Desktop.');\n else notes.push('About ' + minutes(device.budget.seconds) + ' minutes at 1080p fit on this device' + (device.budget.bytes ? ' (' + formatBytes(device.budget.bytes) + ')' : '') + '. StillMade Desktop records longer.');\n if (device.cut === 'playback') notes.push('Cuts here take as long as the part you keep.');\n if (device.cut === 'none') notes.push('This browser cannot cut recordings; the whole recording can still be used, or cut it in StillMade Desktop.');\n return notes.join(' ');\n}\nasync function loadDevice() {\n let problem = null;\n try { state.device = await StillMade.desktop('device.info'); } catch (error) { state.device = null; problem = message(error); }\n say('capacity', state.device ? describeDevice(state.device) : `Could not check what fits on this device: ${problem}`);\n $('capacity').dataset.warn = String(!!problem || (!!state.device && (!state.device.recorder || state.device.budget.seconds < 60)));\n}\nfunction describeLive(status) {\n const limits = status.limits || {};\n const parts = [];\n let warn = false;\n if (limits.seconds) {\n const left = Math.max(0, limits.seconds - (status.elapsed || 0) / 1000);\n if (left <= limits.seconds * 0.2) { warn = true; parts.push(formatTime(left) + ' left before the time limit.'); }\n }\n if (limits.bytes) {\n const left = Math.max(0, limits.bytes - (status.bytes || 0));\n if (left <= limits.bytes * 0.2) { warn = true; parts.push(formatBytes(left) + ' of space left here. The recording stops and is kept when it runs out.'); }\n }\n return { text: parts.join(' '), warn };\n}\n\n// ── Record ─────────────────────────────────────────────────────────────────\nasync function pollStatus() {\n if (state.phase !== 'live') return;\n try {\n const status = await StillMade.desktop('record.status');\n if (state.phase !== 'live') return;\n if (status.active) {\n say('timer', formatTime((status.elapsed || 0) / 1000));\n say('size', formatBytes(status.bytes));\n const live = describeLive(status);\n say('live-capacity', status.ended ? '' : live.text); $('live-capacity').dataset.warn = String(live.warn && !status.ended);\n if (status.ended) say('live-status', `${status.message || 'The recording stopped.'} Press Stop and edit to keep it.`);\n }\n } catch (error) { say('live-status', message(error)); }\n state.poll = setTimeout(pollStatus, 1000);\n}\n$('start').addEventListener('click', async () => {\n busy(true);\n say('ready-status', 'Press Start in the StillMade controls above, then choose what to record.');\n try {\n await StillMade.desktop('record.start', { kind: 'screen', microphone: $('mic').checked, systemAudio: $('system').checked, resolution: $('resolution').value });\n say('timer', '0:00.0'); say('size', '0 KB'); say('live-status', 'Recording. Press Stop and edit when you are done.'); say('live-capacity', '');\n show('live'); pollStatus();\n } catch (error) { say('ready-status', message(error)); }\n finally { busy(false); }\n});\n$('stop').addEventListener('click', async () => {\n busy(true); clearTimeout(state.poll);\n say('live-status', 'Saving the recording on this device…');\n try {\n const result = await StillMade.desktop('record.stop');\n const asset = { ...result.asset };\n for (const key of ['duration', 'width', 'height']) if (result[key] && !asset[key]) asset[key] = result[key];\n await openEditor(asset);\n say('editor-status', result.stoppedBy ? result.message : '');\n } catch (error) { say('live-status', message(error)); state.phase = 'live'; pollStatus(); }\n finally { busy(false); }\n});\n$('discard').addEventListener('click', async () => {\n busy(true); clearTimeout(state.poll);\n try { await StillMade.desktop('record.cancel'); } catch (error) { /* already gone */ }\n say('ready-status', 'Discarded. Ready to record again.'); show('ready'); busy(false);\n});\nconst recordAgain = () => { closePreview(); state.source = null; say('ready-status', 'Ready to record.'); show('ready'); loadDevice(); };\n$('again').addEventListener('click', recordAgain);\n$('record-again').addEventListener('click', recordAgain);\n\n// ── Editor ─────────────────────────────────────────────────────────────────\nfunction closePreview() {\n player.pause();\n if (state.previewUrl) { try { StillMade.releaseMedia(state.previewUrl); } catch (error) { /* released */ } state.previewUrl = null; }\n player.removeAttribute('src'); player.load();\n}\nasync function openEditor(source) {\n closePreview();\n state.source = source; state.selected = null; state.undo = [];\n const { url } = await StillMade.previewMedia(source);\n state.previewUrl = url;\n const duration = await new Promise((resolve, reject) => {\n const done = () => { player.onloadedmetadata = null; player.onerror = null; resolve(Number.isFinite(player.duration) && player.duration > 0 ? player.duration : Number(source.duration) || 0); };\n player.onloadedmetadata = done;\n player.onerror = () => reject(new Error('This recording could not be opened for editing.'));\n player.src = url;\n });\n if (!(duration > 0)) throw new Error('The recording has no length to edit.');\n state.duration = duration;\n state.segments = createSegments(duration);\n renderTimeline(); updateTime(); updateButtons();\n say('editor-status', '');\n show('editor');\n}\nconst fraction = time => Math.max(0, Math.min(1, time / state.duration));\nfunction renderTimeline() {\n segmentsNode.replaceChildren();\n state.segments.forEach((segment, index) => {\n const node = document.createElement('button');\n node.type = 'button'; node.className = 'sr-seg'; node.dataset.id = String(segment.id); node.dataset.kept = String(segment.kept);\n node.setAttribute('aria-pressed', String(state.selected === segment.id));\n node.setAttribute('aria-label', `${segment.kept ? 'Kept' : 'Removed'} part from ${formatTime(segment.start)} to ${formatTime(segment.end)}`);\n node.style.left = `${fraction(segment.start) * 100}%`; node.style.width = `${(fraction(segment.end) - fraction(segment.start)) * 100}%`;\n node.addEventListener('click', event => { state.selected = state.selected === segment.id ? null : segment.id; seekTo(timeAt(event.clientX)); renderTimeline(); updateButtons(); });\n segmentsNode.append(node);\n if (index > 0) {\n const handle = document.createElement('button');\n handle.type = 'button'; handle.className = 'sr-handle'; handle.dataset.index = String(index);\n handle.setAttribute('aria-label', `Move the cut at ${formatTime(segment.start)}`);\n handle.style.left = `${fraction(segment.start) * 100}%`;\n handle.addEventListener('pointerdown', beginDrag);\n segmentsNode.append(handle);\n }\n });\n say('kept', isWhole(state.segments) ? `${formatTime(state.duration)} kept` : `${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)} kept`);\n}\nfunction timeAt(clientX) {\n const box = timeline.getBoundingClientRect();\n return box.width > 0 ? Math.max(0, Math.min(state.duration, (clientX - box.left) / box.width * state.duration)) : 0;\n}\nfunction beginDrag(event) {\n event.preventDefault(); event.stopPropagation();\n const index = Number(event.currentTarget.dataset.index);\n state.dragging = { index, before: state.segments, handle: event.currentTarget, pointerId: event.pointerId };\n event.currentTarget.setPointerCapture(event.pointerId);\n event.currentTarget.dataset.active = 'true';\n event.currentTarget.addEventListener('pointermove', continueDrag);\n event.currentTarget.addEventListener('pointerup', endDrag);\n event.currentTarget.addEventListener('pointercancel', endDrag);\n}\nfunction continueDrag(event) {\n const drag = state.dragging; if (!drag) return;\n const next = moveBoundary(state.segments, drag.index, timeAt(event.clientX));\n if (next === state.segments) return;\n state.segments = next;\n const boundary = next[drag.index].start;\n drag.handle.style.left = `${fraction(boundary) * 100}%`;\n const left = segmentsNode.querySelector(`.sr-seg[data-id=\"${next[drag.index - 1].id}\"]`), right = segmentsNode.querySelector(`.sr-seg[data-id=\"${next[drag.index].id}\"]`);\n if (left) left.style.width = `${(fraction(boundary) - fraction(next[drag.index - 1].start)) * 100}%`;\n if (right) { right.style.left = `${fraction(boundary) * 100}%`; right.style.width = `${(fraction(next[drag.index].end) - fraction(boundary)) * 100}%`; }\n seekTo(boundary, { quiet: true });\n}\nfunction endDrag(event) {\n const drag = state.dragging; if (!drag) return;\n state.dragging = null;\n drag.handle.dataset.active = 'false';\n drag.handle.removeEventListener('pointermove', continueDrag);\n drag.handle.removeEventListener('pointerup', endDrag);\n drag.handle.removeEventListener('pointercancel', endDrag);\n try { drag.handle.releasePointerCapture(drag.pointerId); } catch (error) { /* released */ }\n if (drag.before !== state.segments) { state.undo.push(drag.before); if (state.undo.length > 100) state.undo.shift(); }\n renderTimeline(); updateButtons();\n}\ntimeline.addEventListener('pointerdown', event => { if (event.target === timeline || event.target === segmentsNode) seekTo(timeAt(event.clientX)); });\nfunction seekTo(time, options) {\n player.currentTime = Math.max(0, Math.min(state.duration, time));\n if (!(options && options.quiet)) updateTime();\n}\nfunction updateTime() {\n const time = player.currentTime || 0;\n playhead.style.transform = `translateX(${fraction(time) * timeline.clientWidth}px)`;\n say('time', `${formatTime(time)} / ${formatTime(state.duration)}`);\n}\nplayer.addEventListener('timeupdate', () => {\n if (state.phase !== 'editor') return;\n if ($('skip').checked && !player.paused) {\n const next = nextKeptTime(state.segments, player.currentTime);\n if (next === null) { player.pause(); }\n else if (next !== player.currentTime) { player.currentTime = next; }\n }\n updateTime();\n});\nplayer.addEventListener('play', () => { if ($('skip').checked) { const next = nextKeptTime(state.segments, player.currentTime); if (next === null) player.currentTime = firstKeptTime(state.segments); else if (next !== player.currentTime) player.currentTime = next; } $('play').textContent = 'Pause'; });\nplayer.addEventListener('pause', () => { $('play').textContent = 'Play'; });\nplayer.addEventListener('ended', () => { $('play').textContent = 'Play'; });\n$('play').addEventListener('click', () => { if (player.paused) player.play().catch(error => say('editor-status', message(error))); else player.pause(); });\nfunction apply(next) {\n if (next === state.segments) return;\n state.undo.push(state.segments); if (state.undo.length > 100) state.undo.shift();\n state.segments = next;\n renderTimeline(); updateButtons();\n}\n$('cut').addEventListener('click', () => { apply(splitAt(state.segments, player.currentTime)); });\n$('toggle').addEventListener('click', () => { if (state.selected !== null) apply(toggleKept(state.segments, state.selected)); });\n$('undo').addEventListener('click', () => { const previous = state.undo.pop(); if (previous) { state.segments = previous; if (!previous.some(segment => segment.id === state.selected)) state.selected = null; renderTimeline(); updateButtons(); } });\ntimeline.addEventListener('keydown', event => {\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); seekTo((player.currentTime || 0) + (event.key === 'ArrowLeft' ? -0.5 : 0.5) * (event.shiftKey ? 10 : 1)); }\n else if (event.key === 'c' || event.key === 'C') { event.preventDefault(); $('cut').click(); }\n else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); $('toggle').click(); }\n else if (event.key === ' ') { event.preventDefault(); $('play').click(); }\n});\nfunction updateButtons() {\n const selected = state.segments.find(segment => segment.id === state.selected);\n $('toggle').disabled = !selected; $('toggle').dataset.off = String(!selected);\n $('toggle').textContent = selected && !selected.kept ? 'Keep part' : 'Remove part';\n $('undo').disabled = !state.undo.length; $('undo').dataset.off = String(!state.undo.length);\n const whole = isWhole(state.segments), nothing = keepRanges(state.segments).length === 0;\n $('save').textContent = whole ? 'Use the whole recording' : 'Save the cut';\n $('save').disabled = nothing; $('save').dataset.off = String(nothing);\n}\nwindow.addEventListener('resize', () => { if (state.phase === 'editor') updateTime(); });\n\n// ── Save ───────────────────────────────────────────────────────────────────\n$('save').addEventListener('click', async () => {\n const source = state.source; if (!source) return;\n const keep = keepRanges(state.segments);\n if (!keep.length) { say('editor-status', 'Keep at least one part.'); return; }\n busy(true); player.pause();\n try {\n let video = source;\n if (!isWhole(state.segments)) {\n say('editor-status', 'Cutting on this device…');\n let watching = true;\n const watch = async () => { while (watching) { await sleep(700); if (!watching) break; try { const status = await StillMade.desktop('record.status'); if (status.cutting) say('editor-status', `Cutting on this device… ${Math.round(status.cutting.progress * 100)}%`); } catch (error) { /* next tick */ } } };\n watch();\n try { video = (await StillMade.desktop('record.cut', { source, keep })).asset; }\n finally { watching = false; }\n }\n say('editor-status', 'Passing the result on…');\n await StillMade.run({ video, original: source });\n state.lastSaved = video;\n say('done-text', isWhole(state.segments) ? `The whole recording (${formatTime(state.duration)}) is the result.` : `Saved ${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)}. The uncut recording is kept beside it.`);\n show('done');\n } catch (error) { say('editor-status', message(error)); }\n finally { busy(false); }\n});\n$('edit-again').addEventListener('click', () => { if (state.source) { show('editor'); say('editor-status', ''); } });\n\n// ── Input: a video from an earlier Step opens straight in the editor ──────\n// onInput first fires once the view is ready, which is also the earliest moment a host call can be made.\nStillMade.onInput(async input => {\n if (!state.device && !state.deviceAsked) { state.deviceAsked = true; loadDevice(); }\n const video = input && input.video;\n if (!video || state.source || state.phase !== 'ready') return;\n busy(true);\n try { await openEditor(video); say('editor-status', 'Editing the video this Block received. Record again to replace it.'); }\n catch (error) { say('ready-status', message(error)); }\n finally { busy(false); }\n});\n\n})();\n"
96
+ "html": "<main class=\"sm-block sr\">\n <section class=\"sm-stage sr-stage\" id=\"stage\" aria-live=\"polite\">\n <video id=\"player\" class=\"sr-player\" playsinline preload=\"metadata\" hidden></video>\n <div class=\"sm-empty\" id=\"empty\">\n <span class=\"sm-glyph\" data-glyph=\"video\" aria-hidden=\"true\"></span>\n <p>Nothing recorded yet</p>\n </div>\n <div class=\"sr-live\" id=\"meter\" hidden>\n <span class=\"sr-dot\" aria-hidden=\"true\"></span>\n <strong id=\"timer\">0:00.0</strong>\n <span id=\"size\">0 KB</span>\n </div>\n <div id=\"timeline\" class=\"sr-timeline\" role=\"group\" aria-label=\"Recording timeline\" tabindex=\"0\" hidden>\n <div id=\"segments\" class=\"sr-segments\"></div>\n <div id=\"playhead\" class=\"sr-playhead\" aria-hidden=\"true\"></div>\n </div>\n </section>\n <fieldset class=\"sm-choices\" data-compact id=\"sound\">\n <legend>Sound</legend>\n <label><input id=\"mic\" type=\"checkbox\" data-stillmade-share=\"microphone\"><span class=\"sm-glyph\" data-glyph=\"audio\" aria-hidden=\"true\"></span><span>Microphone</span></label>\n <label><input id=\"system\" type=\"checkbox\" data-stillmade-share=\"computerSound\"><span>Computer sound</span></label>\n </fieldset>\n <div class=\"sm-actions\" id=\"tools\" hidden>\n <button type=\"button\" class=\"sm-icon\" id=\"play\" aria-label=\"Play\" title=\"Play\"><span class=\"sm-glyph\" data-glyph=\"play\" aria-hidden=\"true\"></span></button>\n <span class=\"sr-time\" id=\"time\">0:00.0 / 0:00.0</span>\n <button type=\"button\" class=\"sm-quiet\" id=\"cut\">Cut here</button>\n <button type=\"button\" class=\"sm-quiet\" id=\"toggle\" disabled>Remove part</button>\n <button type=\"button\" class=\"sm-quiet\" id=\"undo\" disabled>Undo</button>\n <span class=\"sr-time\" id=\"kept\"></span>\n </div>\n <div class=\"sm-actions\">\n <button type=\"button\" class=\"sm-primary\" id=\"action\">Record screen</button>\n <button type=\"button\" class=\"sm-quiet\" id=\"secondary\" hidden>Record again</button>\n <p class=\"sm-status\" id=\"status\" role=\"status\"></p>\n <details class=\"sm-hint\" id=\"why\" hidden><summary aria-label=\"Why\" title=\"Why\"></summary><p id=\"why-text\"></p></details>\n </div>\n <details class=\"sm-more\" id=\"more\">\n <summary>More</summary>\n <div class=\"sm-settings\">\n <fieldset class=\"sm-choices\" data-segmented id=\"resolution\" data-stillmade-share=\"resolution\">\n <legend>Size</legend>\n <label><input type=\"radio\" name=\"resolution\" value=\"1080p\" checked><span>1080p</span></label>\n <label><input type=\"radio\" name=\"resolution\" value=\"source\"><span>Full size</span></label>\n </fieldset>\n <fieldset class=\"sm-choices\" data-compact id=\"playback\" hidden>\n <legend>Playback</legend>\n <label><input id=\"skip\" type=\"checkbox\" checked data-stillmade-local><span>Skip removed parts</span></label>\n </fieldset>\n </div>\n </details>\n</main>\n",
97
+ "css": "/* The Block kit (injected by the host) carries the layout; this file only draws\n what the kit has no piece for: the recording meter and the cut timeline. */\n.sr [hidden] { display: none !important; }\n.sr-stage { display: grid; grid-template-rows: minmax(0, 1fr) auto; align-items: stretch; justify-items: stretch; gap: var(--s-2, 8px); }\n.sr-stage > .sm-empty, .sr-live { align-self: center; justify-self: center; }\n.sr-player { width: 100%; height: 100%; min-height: 0; max-height: 100%; object-fit: contain; border-radius: var(--r-2, 4px); background: var(--bg-inset); }\n#sound { --sm-tile: 150px; }\n.sr-live { display: flex; align-items: center; gap: var(--s-3, 12px); font-variant-numeric: tabular-nums; }\n.sr-live strong { font-size: 1.6rem; font-weight: 500; }\n.sr-live span:last-child { color: var(--fg-muted); }\n.sr-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--attention-error, var(--signal)); }\n.sr-time { color: var(--fg-muted); font-size: var(--fs-body-s, .875rem); font-variant-numeric: tabular-nums; }\n/* Kept parts are solid, removed parts hatched, the chosen part outlined. */\n.sr-timeline { position: relative; height: 48px; border-radius: var(--r-3, 8px); background: var(--bg-elev-1); border: 1px solid var(--border-tok); overflow: hidden; touch-action: none; user-select: none; }\n.sr-timeline:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }\n.sr-segments { position: absolute; inset: 0; }\n.sr-seg { position: absolute; top: 0; bottom: 0; border: 0; padding: 0; background: var(--fg-muted); opacity: .5; cursor: pointer; }\n.sr-seg[data-kept=\"false\"] { background: repeating-linear-gradient(135deg, transparent 0 6px, var(--border-strong) 6px 8px); opacity: 1; }\n.sr-seg[aria-pressed=\"true\"] { box-shadow: inset 0 0 0 2px var(--fg); opacity: 1; }\n.sr-seg:focus-visible { outline: 2px solid var(--signal); outline-offset: -2px; }\n.sr-handle { position: absolute; top: 0; bottom: 0; width: 16px; margin-left: -8px; cursor: ew-resize; background: transparent; border: 0; padding: 0; }\n.sr-handle::after { content: \"\"; position: absolute; top: 4px; bottom: 4px; left: 6px; width: 4px; border-radius: 2px; background: var(--bg); box-shadow: 0 0 0 1px var(--fg); }\n.sr-handle:hover::after, .sr-handle[data-active=\"true\"]::after { background: var(--fg); }\n.sr-playhead { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--fg); pointer-events: none; }\n",
98
+ "javascript": "(()=>{\n\"use strict\";\n// The cut editor's model: the recording is an ordered list of segments that\n// together cover it, each kept or removed. A cut splits the segment under the\n// playhead, removing a segment drops it from the result, and dragging a\n// boundary trims both neighbours. Every change returns a new array, so undo\n// keeps the earlier ones. Times are seconds of the source recording.\nconst MIN_SEGMENT = 0.1;\nlet nextSegmentId = 1;\nfunction createSegments(duration) {\n nextSegmentId = 1;\n return [{ id: nextSegmentId++, start: 0, end: duration, kept: true }];\n}\nfunction segmentAt(segments, time) {\n const found = segments.find(segment => time >= segment.start && time < segment.end);\n if (found) return found;\n const last = segments[segments.length - 1];\n return last && time >= last.end ? last : null;\n}\nfunction splitAt(segments, time) {\n const index = segments.findIndex(segment => time > segment.start + MIN_SEGMENT && time < segment.end - MIN_SEGMENT);\n if (index < 0) return segments;\n const segment = segments[index];\n return [...segments.slice(0, index), { ...segment, end: time }, { id: nextSegmentId++, start: time, end: segment.end, kept: segment.kept }, ...segments.slice(index + 1)];\n}\nfunction toggleKept(segments, id) {\n const index = segments.findIndex(segment => segment.id === id);\n if (index < 0) return segments;\n const next = segments.slice();\n next[index] = { ...next[index], kept: !next[index].kept };\n return next;\n}\n/** Moves the boundary before segments[index], clamped so both neighbours keep a minimum length. */\nfunction moveBoundary(segments, index, time) {\n if (index < 1 || index >= segments.length) return segments;\n const left = segments[index - 1], right = segments[index];\n const clamped = Math.min(right.end - MIN_SEGMENT, Math.max(left.start + MIN_SEGMENT, time));\n if (clamped === left.end) return segments;\n const next = segments.slice();\n next[index - 1] = { ...left, end: clamped };\n next[index] = { ...right, start: clamped };\n return next;\n}\n/** Trims the recording's start or end to `time`: the trimmed part becomes a removed segment. */\nfunction trimEdge(segments, edge, time) {\n if (edge === 'start') {\n if (!segments[0].kept) return moveBoundary(segments, 1, time);\n const split = splitAt(segments, time);\n return split === segments ? segments : toggleKept(split, split[0].id);\n }\n const last = segments[segments.length - 1];\n if (!last.kept) return moveBoundary(segments, segments.length - 1, time);\n const split = splitAt(segments, time);\n return split === segments ? segments : toggleKept(split, split[split.length - 1].id);\n}\n/** The kept ranges in order, neighbours merged: what record.cut keeps. */\nfunction keepRanges(segments) {\n const ranges = [];\n for (const segment of segments) {\n if (!segment.kept) continue;\n const last = ranges[ranges.length - 1];\n if (last && Math.abs(last.end - segment.start) < 1e-6) last.end = segment.end;\n else ranges.push({ start: segment.start, end: segment.end });\n }\n return ranges;\n}\nfunction keptDuration(segments) {\n return segments.reduce((sum, segment) => segment.kept ? sum + segment.end - segment.start : sum, 0);\n}\nfunction isWhole(segments) {\n return segments.every(segment => segment.kept);\n}\n/** Where playback continues from `time` when removed parts are skipped: `time` itself when kept, the next kept start, or null when nothing kept follows. */\nfunction nextKeptTime(segments, time) {\n const segment = segmentAt(segments, time);\n if (!segment || segment.kept) return time;\n const later = segments.find(candidate => candidate.kept && candidate.start >= segment.end);\n return later ? later.start : null;\n}\nfunction firstKeptTime(segments) {\n const segment = segments.find(candidate => candidate.kept);\n return segment ? segment.start : 0;\n}\n/** Seconds as m:ss.d */\nfunction formatTime(seconds) {\n const total = Math.max(0, Math.round((Number(seconds) || 0) * 10) / 10);\n const minutes = Math.floor(total / 60), whole = Math.floor(total % 60), tenth = Math.round((total - Math.floor(total)) * 10) % 10;\n return `${minutes}:${String(whole).padStart(2, '0')}.${tenth}`;\n}\nfunction formatBytes(bytes) {\n const value = Number(bytes) || 0;\n return value >= 1024 * 1024 * 1024 ? `${(value / 1024 / 1024 / 1024).toFixed(2)} GB` : value >= 1024 * 1024 ? `${Math.round(value / 1024 / 1024)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;\n}\n\n// The Screen Recorder interface on the Block kit: the stage shows the recording\n// (a meter while recording, the player and the cut timeline after), one button\n// names the next step, and the status beside it says the state in a few words\n// with the reason behind the (i). The host records; this view only asks.\n// Built into src/view.js with the cut model.\nconst $ = id => document.getElementById(id);\nconst player = $('player'), timeline = $('timeline'), segmentsNode = $('segments'), playhead = $('playhead');\nconst state = { phase: 'ready', source: null, segments: [], undo: [], selected: null, duration: 0, previewUrl: null, dragging: null, poll: null, busy: false, device: null, deviceAsked: false, capacity: null };\n\nconst say = (id, text) => { $(id).textContent = text || ''; };\nconst message = error => String(error && error.message || error || 'Something went wrong.');\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n// A few words beside the action, a state dot, and the reason behind the (i).\nfunction status(text, tone, why) {\n const node = $('status');\n node.textContent = text || '';\n if (tone) node.dataset.state = tone; else delete node.dataset.state;\n $('why').hidden = !why; $('why').open = false; say('why-text', why || '');\n}\nconst busy = value => { state.busy = value; for (const id of ['action', 'secondary', 'play', 'cut', 'toggle', 'undo']) $(id).disabled = value || ($(id).dataset.off === 'true'); };\n\n// ── Phases: what the stage, the settings and the action row show ──────────\nconst PHASES = {\n ready: { empty: true, meter: false, player: false, timeline: false, tools: false, sound: true, size: true, playback: false, action: 'Record screen', secondary: '' },\n live: { empty: false, meter: true, player: false, timeline: false, tools: false, sound: false, size: false, playback: false, action: 'Stop and edit', secondary: 'Discard' },\n editor: { empty: false, meter: false, player: true, timeline: true, tools: true, sound: false, size: false, playback: true, action: '', secondary: 'Record again' },\n done: { empty: false, meter: false, player: true, timeline: false, tools: false, sound: false, size: false, playback: false, action: 'Edit again', secondary: 'Record again' },\n};\nfunction show(phase) {\n state.phase = phase;\n const p = PHASES[phase];\n $('empty').hidden = !p.empty; $('meter').hidden = !p.meter; player.hidden = !p.player; timeline.hidden = !p.timeline; $('tools').hidden = !p.tools;\n $('sound').hidden = !p.sound; $('resolution').hidden = !p.size; $('playback').hidden = !p.playback; $('more').hidden = !(p.size || p.playback);\n $('secondary').hidden = !p.secondary; if (p.secondary) say('secondary', p.secondary);\n if (p.action) say('action', p.action);\n $('action').dataset.off = 'false';\n if (phase === 'editor') updateButtons(); else if (phase === 'ready') showCapacity();\n $('action').disabled = state.busy || $('action').dataset.off === 'true';\n}\n$('action').addEventListener('click', () => { ({ ready: start, live: stop, editor: save, done: editAgain })[state.phase](); });\n$('secondary').addEventListener('click', () => { if (state.phase === 'live') discard(); else recordAgain(); });\n\n// ── What fits here ─────────────────────────────────────────────────────────\nconst span = seconds => seconds >= 3600 ? Math.round(seconds / 3600) + ' h' : Math.max(1, Math.round(seconds / 60)) + ' min';\nfunction describeDevice(device) {\n if (!device.recorder) return { text: 'This browser cannot record', tone: 'error', why: 'Use a current desktop browser or StillMade Desktop.', off: true };\n const notes = [];\n if (device.cut === 'playback') notes.push('A cut here takes as long as the part you keep.');\n if (device.cut === 'none') notes.push('This browser cannot cut; use the whole recording, or cut it in StillMade Desktop.');\n if (device.platform === 'desktop') return { text: 'Records to disk, up to ' + span(device.budget.seconds), tone: '', why: notes.join(' ') };\n if (device.budget.seconds < 60) return { text: 'Less than a minute fits here', tone: 'error', why: ['Free up storage, or use StillMade Desktop, which records to disk.', ...notes].join(' ') };\n const fit = span(device.budget.seconds);\n return { text: 'About ' + fit + ' fit here', tone: device.budget.seconds < 300 ? 'waiting' : '', why: [fit + ' at 1080p' + (device.budget.bytes ? ' (' + formatBytes(device.budget.bytes) + ')' : '') + ' fit on this device. StillMade Desktop records longer.', ...notes].join(' ') };\n}\nasync function loadDevice() {\n let problem = null;\n try { state.device = await StillMade.desktop('device.info'); } catch (error) { state.device = null; problem = message(error); }\n state.capacity = problem ? { text: 'Could not check what fits here', tone: 'waiting', why: problem } : describeDevice(state.device);\n showCapacity();\n}\nfunction showCapacity() {\n if (state.phase !== 'ready' || !state.capacity) return;\n const capacity = state.capacity;\n status(capacity.text, capacity.tone, capacity.why);\n $('action').dataset.off = String(!!capacity.off); $('action').disabled = state.busy || !!capacity.off;\n}\nconst ENDED = { sharing: 'Sharing ended', time: 'Time limit reached', size: 'Out of space here', disk: 'Could not write to disk' };\nfunction describeLive(live) {\n const limits = live.limits || {};\n if (limits.seconds) {\n const left = Math.max(0, limits.seconds - (live.elapsed || 0) / 1000);\n if (left <= limits.seconds * 0.2) return { text: formatTime(left) + ' left', why: 'The recording stops at ' + span(limits.seconds) + ' and is kept up to that point.' };\n }\n if (limits.bytes) {\n const left = Math.max(0, limits.bytes - (live.bytes || 0));\n if (left <= limits.bytes * 0.2) return { text: formatBytes(left) + ' left', why: 'The recording stops when the space here runs out and is kept up to that point.' };\n }\n return null;\n}\n\n// ── Record ─────────────────────────────────────────────────────────────────\nasync function pollStatus() {\n if (state.phase !== 'live') return;\n try {\n const live = await StillMade.desktop('record.status');\n if (state.phase !== 'live') return;\n if (live.active) {\n say('timer', formatTime((live.elapsed || 0) / 1000));\n say('size', formatBytes(live.bytes));\n if (live.ended) status(ENDED[live.stoppedBy] || 'Recording stopped', 'waiting', (live.message || 'The recording stopped.') + ' Press Stop and edit to keep it.');\n else { const warning = describeLive(live); if (warning) status(warning.text, 'waiting', warning.why); else status('Recording', 'loading'); }\n }\n } catch (error) { status(message(error), 'error'); }\n state.poll = setTimeout(pollStatus, 1000);\n}\nasync function start() {\n busy(true);\n status('Press Start above', 'waiting', 'Then choose the screen or window to record. Nothing is captured before that.');\n try {\n const picked = document.querySelector('input[name=\"resolution\"]:checked');\n await StillMade.desktop('record.start', { kind: 'screen', microphone: $('mic').checked, systemAudio: $('system').checked, resolution: picked ? picked.value : '1080p' });\n say('timer', '0:00.0'); say('size', '0 KB');\n show('live'); status('Recording', 'loading'); pollStatus();\n } catch (error) { status(message(error), 'error'); }\n finally { busy(false); }\n}\nasync function stop() {\n busy(true); clearTimeout(state.poll);\n status('Saving on this device', 'loading');\n try {\n const result = await StillMade.desktop('record.stop');\n const asset = { ...result.asset };\n for (const key of ['duration', 'width', 'height']) if (result[key] && !asset[key]) asset[key] = result[key];\n await openEditor(asset);\n if (result.stoppedBy) status(ENDED[result.stoppedBy] || 'Recording stopped', 'waiting', result.message); else status('');\n } catch (error) { status(message(error), 'error'); pollStatus(); }\n finally { busy(false); }\n}\nasync function discard() {\n busy(true); clearTimeout(state.poll);\n try { await StillMade.desktop('record.cancel'); } catch (error) { /* already gone */ }\n show('ready'); busy(false);\n}\nfunction recordAgain() { closePreview(); state.source = null; show('ready'); loadDevice(); }\n\n// ── Editor ─────────────────────────────────────────────────────────────────\nfunction closePreview() {\n player.pause();\n if (state.previewUrl) { try { StillMade.releaseMedia(state.previewUrl); } catch (error) { /* released */ } state.previewUrl = null; }\n player.removeAttribute('src'); player.load();\n}\nasync function openEditor(source) {\n closePreview();\n state.source = source; state.selected = null; state.undo = [];\n const { url } = await StillMade.previewMedia(source);\n state.previewUrl = url;\n const duration = await new Promise((resolve, reject) => {\n const done = () => { player.onloadedmetadata = null; player.onerror = null; resolve(Number.isFinite(player.duration) && player.duration > 0 ? player.duration : Number(source.duration) || 0); };\n player.onloadedmetadata = done;\n player.onerror = () => reject(new Error('This recording could not be opened for editing.'));\n player.src = url;\n });\n if (!(duration > 0)) throw new Error('The recording has no length to edit.');\n state.duration = duration;\n state.segments = createSegments(duration);\n show('editor'); status('');\n renderTimeline(); updateTime(); updateButtons();\n}\nconst fraction = time => Math.max(0, Math.min(1, time / state.duration));\nfunction renderTimeline() {\n segmentsNode.replaceChildren();\n state.segments.forEach((segment, index) => {\n const node = document.createElement('button');\n node.type = 'button'; node.className = 'sr-seg'; node.dataset.id = String(segment.id); node.dataset.kept = String(segment.kept);\n node.setAttribute('aria-pressed', String(state.selected === segment.id));\n node.setAttribute('aria-label', `${segment.kept ? 'Kept' : 'Removed'} part from ${formatTime(segment.start)} to ${formatTime(segment.end)}`);\n node.style.left = `${fraction(segment.start) * 100}%`; node.style.width = `${(fraction(segment.end) - fraction(segment.start)) * 100}%`;\n node.addEventListener('click', event => { state.selected = state.selected === segment.id ? null : segment.id; seekTo(timeAt(event.clientX)); renderTimeline(); updateButtons(); });\n segmentsNode.append(node);\n if (index > 0) {\n const handle = document.createElement('button');\n handle.type = 'button'; handle.className = 'sr-handle'; handle.dataset.index = String(index);\n handle.setAttribute('aria-label', `Move the cut at ${formatTime(segment.start)}`);\n handle.style.left = `${fraction(segment.start) * 100}%`;\n handle.addEventListener('pointerdown', beginDrag);\n segmentsNode.append(handle);\n }\n });\n say('kept', isWhole(state.segments) ? `${formatTime(state.duration)} kept` : `${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)} kept`);\n}\nfunction timeAt(clientX) {\n const box = timeline.getBoundingClientRect();\n return box.width > 0 ? Math.max(0, Math.min(state.duration, (clientX - box.left) / box.width * state.duration)) : 0;\n}\nfunction beginDrag(event) {\n event.preventDefault(); event.stopPropagation();\n const index = Number(event.currentTarget.dataset.index);\n state.dragging = { index, before: state.segments, handle: event.currentTarget, pointerId: event.pointerId };\n event.currentTarget.setPointerCapture(event.pointerId);\n event.currentTarget.dataset.active = 'true';\n event.currentTarget.addEventListener('pointermove', continueDrag);\n event.currentTarget.addEventListener('pointerup', endDrag);\n event.currentTarget.addEventListener('pointercancel', endDrag);\n}\nfunction continueDrag(event) {\n const drag = state.dragging; if (!drag) return;\n const next = moveBoundary(state.segments, drag.index, timeAt(event.clientX));\n if (next === state.segments) return;\n state.segments = next;\n const boundary = next[drag.index].start;\n drag.handle.style.left = `${fraction(boundary) * 100}%`;\n const left = segmentsNode.querySelector(`.sr-seg[data-id=\"${next[drag.index - 1].id}\"]`), right = segmentsNode.querySelector(`.sr-seg[data-id=\"${next[drag.index].id}\"]`);\n if (left) left.style.width = `${(fraction(boundary) - fraction(next[drag.index - 1].start)) * 100}%`;\n if (right) { right.style.left = `${fraction(boundary) * 100}%`; right.style.width = `${(fraction(next[drag.index].end) - fraction(boundary)) * 100}%`; }\n seekTo(boundary, { quiet: true });\n}\nfunction endDrag(event) {\n const drag = state.dragging; if (!drag) return;\n state.dragging = null;\n drag.handle.dataset.active = 'false';\n drag.handle.removeEventListener('pointermove', continueDrag);\n drag.handle.removeEventListener('pointerup', endDrag);\n drag.handle.removeEventListener('pointercancel', endDrag);\n try { drag.handle.releasePointerCapture(drag.pointerId); } catch (error) { /* released */ }\n if (drag.before !== state.segments) { state.undo.push(drag.before); if (state.undo.length > 100) state.undo.shift(); }\n renderTimeline(); updateButtons();\n}\ntimeline.addEventListener('pointerdown', event => { if (event.target === timeline || event.target === segmentsNode) seekTo(timeAt(event.clientX)); });\nfunction seekTo(time, options) {\n player.currentTime = Math.max(0, Math.min(state.duration, time));\n if (!(options && options.quiet)) updateTime();\n}\nfunction updateTime() {\n const time = player.currentTime || 0;\n playhead.style.transform = `translateX(${fraction(time) * timeline.clientWidth}px)`;\n say('time', `${formatTime(time)} / ${formatTime(state.duration)}`);\n}\nconst playGlyph = paused => { const glyph = $('play').querySelector('.sm-glyph'); glyph.dataset.glyph = paused ? 'play' : 'pause'; $('play').setAttribute('aria-label', paused ? 'Play' : 'Pause'); $('play').title = paused ? 'Play' : 'Pause'; };\nplayer.addEventListener('timeupdate', () => {\n if (state.phase !== 'editor') return;\n if ($('skip').checked && !player.paused) {\n const next = nextKeptTime(state.segments, player.currentTime);\n if (next === null) { player.pause(); }\n else if (next !== player.currentTime) { player.currentTime = next; }\n }\n updateTime();\n});\nplayer.addEventListener('play', () => { if ($('skip').checked) { const next = nextKeptTime(state.segments, player.currentTime); if (next === null) player.currentTime = firstKeptTime(state.segments); else if (next !== player.currentTime) player.currentTime = next; } playGlyph(false); });\nplayer.addEventListener('pause', () => playGlyph(true));\nplayer.addEventListener('ended', () => playGlyph(true));\n$('play').addEventListener('click', () => { if (player.paused) player.play().catch(error => status(message(error), 'error')); else player.pause(); });\nfunction apply(next) {\n if (next === state.segments) return;\n state.undo.push(state.segments); if (state.undo.length > 100) state.undo.shift();\n state.segments = next;\n renderTimeline(); updateButtons();\n}\n$('cut').addEventListener('click', () => { apply(splitAt(state.segments, player.currentTime)); });\n$('toggle').addEventListener('click', () => { if (state.selected !== null) apply(toggleKept(state.segments, state.selected)); });\n$('undo').addEventListener('click', () => { const previous = state.undo.pop(); if (previous) { state.segments = previous; if (!previous.some(segment => segment.id === state.selected)) state.selected = null; renderTimeline(); updateButtons(); } });\ntimeline.addEventListener('keydown', event => {\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); seekTo((player.currentTime || 0) + (event.key === 'ArrowLeft' ? -0.5 : 0.5) * (event.shiftKey ? 10 : 1)); }\n else if (event.key === 'c' || event.key === 'C') { event.preventDefault(); $('cut').click(); }\n else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); $('toggle').click(); }\n else if (event.key === ' ') { event.preventDefault(); $('play').click(); }\n});\nfunction updateButtons() {\n const selected = state.segments.find(segment => segment.id === state.selected);\n $('toggle').disabled = state.busy || !selected; $('toggle').dataset.off = String(!selected);\n say('toggle', selected && !selected.kept ? 'Keep part' : 'Remove part');\n $('undo').disabled = state.busy || !state.undo.length; $('undo').dataset.off = String(!state.undo.length);\n const whole = isWhole(state.segments), nothing = keepRanges(state.segments).length === 0;\n say('action', whole ? 'Use the whole recording' : 'Save the cut');\n $('action').disabled = state.busy || nothing; $('action').dataset.off = String(nothing);\n}\nwindow.addEventListener('resize', () => { if (state.phase === 'editor') updateTime(); });\n\n// ── Save ───────────────────────────────────────────────────────────────────\nasync function save() {\n const source = state.source; if (!source) return;\n const keep = keepRanges(state.segments);\n if (!keep.length) { status('Keep at least one part', 'error'); return; }\n busy(true); player.pause();\n try {\n let video = source;\n if (!isWhole(state.segments)) {\n status('Cutting on this device', 'loading');\n let watching = true;\n const watch = async () => { while (watching) { await sleep(700); if (!watching) break; try { const live = await StillMade.desktop('record.status'); if (live.cutting) status(`Cutting ${Math.round(live.cutting.progress * 100)}%`, 'loading'); } catch (error) { /* next tick */ } } };\n watch();\n try { video = (await StillMade.desktop('record.cut', { source, keep })).asset; }\n finally { watching = false; }\n }\n status('Passing it on', 'loading');\n await StillMade.run({ video, original: source });\n show('done');\n if (isWhole(state.segments)) status(`Whole recording, ${formatTime(state.duration)}`, 'success');\n else status(`Saved ${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)}`, 'success', 'The uncut recording is passed on beside it.');\n } catch (error) { status(message(error), 'error'); }\n finally { busy(false); }\n}\nfunction editAgain() { if (state.source) { show('editor'); status(''); } }\n\n// ── Input: a video from an earlier Step opens straight in the editor ──────\n// onInput first fires once the view is ready, which is also the earliest moment a host call can be made.\nStillMade.onInput(async input => {\n if (!state.deviceAsked) { state.deviceAsked = true; loadDevice(); }\n const video = input && input.video;\n if (!video || state.source || state.phase !== 'ready') return;\n busy(true);\n try { await openEditor(video); status('Video from an earlier Step', '', 'Record again to replace it.'); }\n catch (error) { status(message(error), 'error'); }\n finally { busy(false); }\n});\n\n})();\n"
99
99
  },
100
100
  "tests": [
101
101
  {
@@ -213,7 +213,7 @@ export default {
213
213
  "files": {
214
214
  "upstream/0/LICENSE": "MIT License\n\nCopyright (c) <year> <copyright holders>\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",
215
215
  "upstream/0/src/run.js": "// The interface records, edits and cuts on the device (StillMade.desktop\n// record.start / record.stop / record.cut). This run only hands the chosen\n// video, and the uncut original, on as typed outputs.\nconst video = input.video;\nif (!video) throw new Error('Record a screen or choose a video first.');\nreturn { video: video, original: input.original || video };\n",
216
- "upstream/0/src/view.json": "{\n \"runtime\": \"frame\",\n \"html\": \"<main class=\\\"sm-block sr\\\" data-layout=\\\"stack\\\">\\n <section id=\\\"ready\\\" class=\\\"sr-panel\\\">\\n <p class=\\\"sr-lead\\\">Record a screen or window. The recording stays on this device until you pass it on.</p>\\n <p class=\\\"sr-capacity\\\" id=\\\"capacity\\\" role=\\\"status\\\"></p>\\n <div class=\\\"sr-options\\\">\\n <label class=\\\"sr-check\\\"><input id=\\\"mic\\\" type=\\\"checkbox\\\" data-stillmade-share=\\\"microphone\\\"> Microphone</label>\\n <label class=\\\"sr-check\\\"><input id=\\\"system\\\" type=\\\"checkbox\\\" data-stillmade-share=\\\"computerSound\\\"> Computer sound</label>\\n <label class=\\\"sr-select\\\">Size <select id=\\\"resolution\\\" data-stillmade-share=\\\"resolution\\\"><option value=\\\"1080p\\\" selected>1080p</option><option value=\\\"source\\\">Full size</option></select></label>\\n </div>\\n <div class=\\\"sm-actions\\\">\\n <button type=\\\"button\\\" class=\\\"sm-primary\\\" id=\\\"start\\\">Start recording</button>\\n <p class=\\\"sm-status\\\" id=\\\"ready-status\\\" role=\\\"status\\\">Nothing is captured until you press Start in the StillMade controls above.</p>\\n </div>\\n </section>\\n <section id=\\\"live\\\" class=\\\"sr-panel\\\" hidden>\\n <div class=\\\"sr-live\\\">\\n <span class=\\\"sr-dot\\\" aria-hidden=\\\"true\\\"></span>\\n <strong id=\\\"timer\\\" class=\\\"sr-timer\\\">0:00.0</strong>\\n <span id=\\\"size\\\" class=\\\"sr-size\\\">0 KB</span>\\n </div>\\n <div class=\\\"sm-actions\\\">\\n <button type=\\\"button\\\" class=\\\"sm-primary\\\" id=\\\"stop\\\">Stop and edit</button>\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"discard\\\">Discard</button>\\n <p class=\\\"sm-status\\\" id=\\\"live-status\\\" role=\\\"status\\\">Recording. Press Stop and edit when you are done.</p>\\n <p class=\\\"sr-capacity\\\" id=\\\"live-capacity\\\" role=\\\"status\\\"></p>\\n </div>\\n </section>\\n <section id=\\\"editor\\\" class=\\\"sr-panel\\\" hidden>\\n <video id=\\\"player\\\" class=\\\"sr-player\\\" playsinline preload=\\\"metadata\\\"></video>\\n <div class=\\\"sr-transport\\\">\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"play\\\" aria-label=\\\"Play\\\">Play</button>\\n <span id=\\\"time\\\" class=\\\"sr-time\\\">0:00.0 / 0:00.0</span>\\n <label class=\\\"sr-check sr-skip\\\"><input id=\\\"skip\\\" type=\\\"checkbox\\\" checked data-stillmade-local> Skip removed parts</label>\\n </div>\\n <div id=\\\"timeline\\\" class=\\\"sr-timeline\\\" role=\\\"group\\\" aria-label=\\\"Recording timeline\\\" tabindex=\\\"0\\\">\\n <div id=\\\"segments\\\" class=\\\"sr-segments\\\"></div>\\n <div id=\\\"playhead\\\" class=\\\"sr-playhead\\\" aria-hidden=\\\"true\\\"></div>\\n </div>\\n <div class=\\\"sr-tools\\\">\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"cut\\\">Cut here</button>\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"toggle\\\" disabled>Remove part</button>\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"undo\\\" disabled>Undo</button>\\n <span id=\\\"kept\\\" class=\\\"sr-kept\\\"></span>\\n </div>\\n <div class=\\\"sm-actions\\\">\\n <button type=\\\"button\\\" class=\\\"sm-primary\\\" id=\\\"save\\\">Use the whole recording</button>\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"again\\\">Record again</button>\\n <p class=\\\"sm-status\\\" id=\\\"editor-status\\\" role=\\\"status\\\"></p>\\n </div>\\n </section>\\n <section id=\\\"done\\\" class=\\\"sr-panel\\\" hidden>\\n <p class=\\\"sr-lead\\\" id=\\\"done-text\\\">Saved.</p>\\n <div class=\\\"sm-actions\\\">\\n <button type=\\\"button\\\" class=\\\"sm-primary\\\" id=\\\"edit-again\\\">Edit again</button>\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"record-again\\\">Record again</button>\\n </div>\\n </section>\\n</main>\\n\",\n \"css\": \".sr { display: block; }\\n.sr-panel { display: grid; gap: 1rem; padding: 1.25rem; }\\n.sr-lead { margin: 0; color: var(--fg-muted); }\\n.sr-capacity { margin: 0; color: var(--fg-muted); font-size: .92em; }\\n.sr-capacity[data-warn=\\\"true\\\"] { color: var(--warning); }\\n.sr-options { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }\\n.sr-check { display: inline-flex; align-items: center; gap: .45rem; color: var(--fg); }\\n.sr-select { display: inline-flex; align-items: center; gap: .45rem; color: var(--fg); }\\n.sr-select select { border: 1px solid var(--border-tok); border-radius: var(--r-3); background: var(--bg-elev-1); color: var(--fg); padding: .35rem .5rem; }\\n.sr-btn { border: 1px solid var(--border-tok); border-radius: 999px; padding: .6rem 1rem; background: var(--bg-elev-1); color: var(--fg); cursor: pointer; }\\n.sr-btn:hover { background: var(--hover-wash); }\\n.sr-btn:disabled, .sm-primary:disabled { opacity: .5; cursor: default; }\\n.sr-live { display: flex; align-items: center; gap: .75rem; }\\n.sr-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--danger); }\\n.sr-timer { font-size: 1.6rem; font-variant-numeric: tabular-nums; }\\n.sr-size { color: var(--fg-muted); font-variant-numeric: tabular-nums; }\\n.sr-player { width: 100%; max-height: 52vh; background: var(--bg-inset); border-radius: var(--r-3); }\\n.sr-transport { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }\\n.sr-time { font-variant-numeric: tabular-nums; color: var(--fg-muted); }\\n.sr-skip { margin-left: auto; color: var(--fg-muted); }\\n.sr-timeline { position: relative; height: 56px; border-radius: var(--r-3); background: var(--bg-inset); border: 1px solid var(--border-tok); overflow: hidden; touch-action: none; user-select: none; }\\n.sr-timeline:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\\n.sr-segments { position: absolute; inset: 0; }\\n.sr-seg { position: absolute; top: 0; bottom: 0; border: 0; padding: 0; background: var(--accent-dim); cursor: pointer; }\\n.sr-seg[data-kept=\\\"false\\\"] { background: var(--hover-wash); opacity: .55; }\\n.sr-seg[aria-pressed=\\\"true\\\"] { box-shadow: inset 0 0 0 2px var(--accent); opacity: 1; }\\n.sr-handle { position: absolute; top: 0; bottom: 0; width: 16px; margin-left: -8px; cursor: ew-resize; background: transparent; border: 0; padding: 0; }\\n.sr-handle::after { content: \\\"\\\"; position: absolute; top: 6px; bottom: 6px; left: 6px; width: 4px; border-radius: 2px; background: var(--fg); opacity: .7; }\\n.sr-handle:hover::after, .sr-handle[data-active=\\\"true\\\"]::after { background: var(--accent); opacity: 1; }\\n.sr-playhead { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--fg); pointer-events: none; }\\n.sr-tools { display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; }\\n.sr-kept { margin-left: auto; color: var(--fg-muted); font-variant-numeric: tabular-nums; }\\n@media (max-width: 480px) { .sr-panel { padding: 1rem; } .sr-skip { margin-left: 0; } }\\n\",\n \"javascript\": \"(()=>{\\n\\\"use strict\\\";\\n// The cut editor's model: the recording is an ordered list of segments that\\n// together cover it, each kept or removed. A cut splits the segment under the\\n// playhead, removing a segment drops it from the result, and dragging a\\n// boundary trims both neighbours. Every change returns a new array, so undo\\n// keeps the earlier ones. Times are seconds of the source recording.\\nconst MIN_SEGMENT = 0.1;\\nlet nextSegmentId = 1;\\nfunction createSegments(duration) {\\n nextSegmentId = 1;\\n return [{ id: nextSegmentId++, start: 0, end: duration, kept: true }];\\n}\\nfunction segmentAt(segments, time) {\\n const found = segments.find(segment => time >= segment.start && time < segment.end);\\n if (found) return found;\\n const last = segments[segments.length - 1];\\n return last && time >= last.end ? last : null;\\n}\\nfunction splitAt(segments, time) {\\n const index = segments.findIndex(segment => time > segment.start + MIN_SEGMENT && time < segment.end - MIN_SEGMENT);\\n if (index < 0) return segments;\\n const segment = segments[index];\\n return [...segments.slice(0, index), { ...segment, end: time }, { id: nextSegmentId++, start: time, end: segment.end, kept: segment.kept }, ...segments.slice(index + 1)];\\n}\\nfunction toggleKept(segments, id) {\\n const index = segments.findIndex(segment => segment.id === id);\\n if (index < 0) return segments;\\n const next = segments.slice();\\n next[index] = { ...next[index], kept: !next[index].kept };\\n return next;\\n}\\n/** Moves the boundary before segments[index], clamped so both neighbours keep a minimum length. */\\nfunction moveBoundary(segments, index, time) {\\n if (index < 1 || index >= segments.length) return segments;\\n const left = segments[index - 1], right = segments[index];\\n const clamped = Math.min(right.end - MIN_SEGMENT, Math.max(left.start + MIN_SEGMENT, time));\\n if (clamped === left.end) return segments;\\n const next = segments.slice();\\n next[index - 1] = { ...left, end: clamped };\\n next[index] = { ...right, start: clamped };\\n return next;\\n}\\n/** Trims the recording's start or end to `time`: the trimmed part becomes a removed segment. */\\nfunction trimEdge(segments, edge, time) {\\n if (edge === 'start') {\\n if (!segments[0].kept) return moveBoundary(segments, 1, time);\\n const split = splitAt(segments, time);\\n return split === segments ? segments : toggleKept(split, split[0].id);\\n }\\n const last = segments[segments.length - 1];\\n if (!last.kept) return moveBoundary(segments, segments.length - 1, time);\\n const split = splitAt(segments, time);\\n return split === segments ? segments : toggleKept(split, split[split.length - 1].id);\\n}\\n/** The kept ranges in order, neighbours merged: what record.cut keeps. */\\nfunction keepRanges(segments) {\\n const ranges = [];\\n for (const segment of segments) {\\n if (!segment.kept) continue;\\n const last = ranges[ranges.length - 1];\\n if (last && Math.abs(last.end - segment.start) < 1e-6) last.end = segment.end;\\n else ranges.push({ start: segment.start, end: segment.end });\\n }\\n return ranges;\\n}\\nfunction keptDuration(segments) {\\n return segments.reduce((sum, segment) => segment.kept ? sum + segment.end - segment.start : sum, 0);\\n}\\nfunction isWhole(segments) {\\n return segments.every(segment => segment.kept);\\n}\\n/** Where playback continues from `time` when removed parts are skipped: `time` itself when kept, the next kept start, or null when nothing kept follows. */\\nfunction nextKeptTime(segments, time) {\\n const segment = segmentAt(segments, time);\\n if (!segment || segment.kept) return time;\\n const later = segments.find(candidate => candidate.kept && candidate.start >= segment.end);\\n return later ? later.start : null;\\n}\\nfunction firstKeptTime(segments) {\\n const segment = segments.find(candidate => candidate.kept);\\n return segment ? segment.start : 0;\\n}\\n/** Seconds as m:ss.d */\\nfunction formatTime(seconds) {\\n const total = Math.max(0, Math.round((Number(seconds) || 0) * 10) / 10);\\n const minutes = Math.floor(total / 60), whole = Math.floor(total % 60), tenth = Math.round((total - Math.floor(total)) * 10) % 10;\\n return `${minutes}:${String(whole).padStart(2, '0')}.${tenth}`;\\n}\\nfunction formatBytes(bytes) {\\n const value = Number(bytes) || 0;\\n return value >= 1024 * 1024 * 1024 ? `${(value / 1024 / 1024 / 1024).toFixed(2)} GB` : value >= 1024 * 1024 ? `${Math.round(value / 1024 / 1024)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;\\n}\\n\\n// The Screen Recorder interface: record (the host records, this view only asks),\\n// then a small cut editor over the recording kept on this device, then one\\n// run that hands the result on. Built into src/view.js with the cut model.\\nconst $ = id => document.getElementById(id);\\nconst panels = { ready: $('ready'), live: $('live'), editor: $('editor'), done: $('done') };\\nconst player = $('player'), timeline = $('timeline'), segmentsNode = $('segments'), playhead = $('playhead');\\nconst state = { phase: 'ready', source: null, segments: [], undo: [], selected: null, duration: 0, previewUrl: null, dragging: null, poll: null, busy: false, lastSaved: null, device: null };\\n\\nfunction show(phase) {\\n state.phase = phase;\\n for (const [name, node] of Object.entries(panels)) node.hidden = name !== phase;\\n}\\nconst say = (id, text) => { $(id).textContent = text || ''; };\\nconst message = error => String(error && error.message || error || 'Something went wrong.');\\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\\nconst busy = value => { state.busy = value; for (const id of ['start', 'stop', 'discard', 'cut', 'toggle', 'undo', 'save', 'again', 'edit-again', 'record-again']) $(id).disabled = value || ($(id).dataset.off === 'true'); };\\n\\n// ── What fits here ─────────────────────────────────────────────────────────\\nconst minutes = seconds => Math.max(1, Math.round(seconds / 60));\\nfunction describeDevice(device) {\\n if (!device) return '';\\n if (!device.recorder) return 'This browser cannot record. Use a current desktop browser or StillMade Desktop.';\\n const notes = [];\\n if (device.platform === 'desktop') notes.push('Records straight to disk; up to ' + minutes(device.budget.seconds) + ' minutes per recording.');\\n else if (device.budget.seconds < 60) notes.push('Less than a minute fits on this device. Free up storage or use StillMade Desktop.');\\n else notes.push('About ' + minutes(device.budget.seconds) + ' minutes at 1080p fit on this device' + (device.budget.bytes ? ' (' + formatBytes(device.budget.bytes) + ')' : '') + '. StillMade Desktop records longer.');\\n if (device.cut === 'playback') notes.push('Cuts here take as long as the part you keep.');\\n if (device.cut === 'none') notes.push('This browser cannot cut recordings; the whole recording can still be used, or cut it in StillMade Desktop.');\\n return notes.join(' ');\\n}\\nasync function loadDevice() {\\n let problem = null;\\n try { state.device = await StillMade.desktop('device.info'); } catch (error) { state.device = null; problem = message(error); }\\n say('capacity', state.device ? describeDevice(state.device) : `Could not check what fits on this device: ${problem}`);\\n $('capacity').dataset.warn = String(!!problem || (!!state.device && (!state.device.recorder || state.device.budget.seconds < 60)));\\n}\\nfunction describeLive(status) {\\n const limits = status.limits || {};\\n const parts = [];\\n let warn = false;\\n if (limits.seconds) {\\n const left = Math.max(0, limits.seconds - (status.elapsed || 0) / 1000);\\n if (left <= limits.seconds * 0.2) { warn = true; parts.push(formatTime(left) + ' left before the time limit.'); }\\n }\\n if (limits.bytes) {\\n const left = Math.max(0, limits.bytes - (status.bytes || 0));\\n if (left <= limits.bytes * 0.2) { warn = true; parts.push(formatBytes(left) + ' of space left here. The recording stops and is kept when it runs out.'); }\\n }\\n return { text: parts.join(' '), warn };\\n}\\n\\n// ── Record ─────────────────────────────────────────────────────────────────\\nasync function pollStatus() {\\n if (state.phase !== 'live') return;\\n try {\\n const status = await StillMade.desktop('record.status');\\n if (state.phase !== 'live') return;\\n if (status.active) {\\n say('timer', formatTime((status.elapsed || 0) / 1000));\\n say('size', formatBytes(status.bytes));\\n const live = describeLive(status);\\n say('live-capacity', status.ended ? '' : live.text); $('live-capacity').dataset.warn = String(live.warn && !status.ended);\\n if (status.ended) say('live-status', `${status.message || 'The recording stopped.'} Press Stop and edit to keep it.`);\\n }\\n } catch (error) { say('live-status', message(error)); }\\n state.poll = setTimeout(pollStatus, 1000);\\n}\\n$('start').addEventListener('click', async () => {\\n busy(true);\\n say('ready-status', 'Press Start in the StillMade controls above, then choose what to record.');\\n try {\\n await StillMade.desktop('record.start', { kind: 'screen', microphone: $('mic').checked, systemAudio: $('system').checked, resolution: $('resolution').value });\\n say('timer', '0:00.0'); say('size', '0 KB'); say('live-status', 'Recording. Press Stop and edit when you are done.'); say('live-capacity', '');\\n show('live'); pollStatus();\\n } catch (error) { say('ready-status', message(error)); }\\n finally { busy(false); }\\n});\\n$('stop').addEventListener('click', async () => {\\n busy(true); clearTimeout(state.poll);\\n say('live-status', 'Saving the recording on this device…');\\n try {\\n const result = await StillMade.desktop('record.stop');\\n const asset = { ...result.asset };\\n for (const key of ['duration', 'width', 'height']) if (result[key] && !asset[key]) asset[key] = result[key];\\n await openEditor(asset);\\n say('editor-status', result.stoppedBy ? result.message : '');\\n } catch (error) { say('live-status', message(error)); state.phase = 'live'; pollStatus(); }\\n finally { busy(false); }\\n});\\n$('discard').addEventListener('click', async () => {\\n busy(true); clearTimeout(state.poll);\\n try { await StillMade.desktop('record.cancel'); } catch (error) { /* already gone */ }\\n say('ready-status', 'Discarded. Ready to record again.'); show('ready'); busy(false);\\n});\\nconst recordAgain = () => { closePreview(); state.source = null; say('ready-status', 'Ready to record.'); show('ready'); loadDevice(); };\\n$('again').addEventListener('click', recordAgain);\\n$('record-again').addEventListener('click', recordAgain);\\n\\n// ── Editor ─────────────────────────────────────────────────────────────────\\nfunction closePreview() {\\n player.pause();\\n if (state.previewUrl) { try { StillMade.releaseMedia(state.previewUrl); } catch (error) { /* released */ } state.previewUrl = null; }\\n player.removeAttribute('src'); player.load();\\n}\\nasync function openEditor(source) {\\n closePreview();\\n state.source = source; state.selected = null; state.undo = [];\\n const { url } = await StillMade.previewMedia(source);\\n state.previewUrl = url;\\n const duration = await new Promise((resolve, reject) => {\\n const done = () => { player.onloadedmetadata = null; player.onerror = null; resolve(Number.isFinite(player.duration) && player.duration > 0 ? player.duration : Number(source.duration) || 0); };\\n player.onloadedmetadata = done;\\n player.onerror = () => reject(new Error('This recording could not be opened for editing.'));\\n player.src = url;\\n });\\n if (!(duration > 0)) throw new Error('The recording has no length to edit.');\\n state.duration = duration;\\n state.segments = createSegments(duration);\\n renderTimeline(); updateTime(); updateButtons();\\n say('editor-status', '');\\n show('editor');\\n}\\nconst fraction = time => Math.max(0, Math.min(1, time / state.duration));\\nfunction renderTimeline() {\\n segmentsNode.replaceChildren();\\n state.segments.forEach((segment, index) => {\\n const node = document.createElement('button');\\n node.type = 'button'; node.className = 'sr-seg'; node.dataset.id = String(segment.id); node.dataset.kept = String(segment.kept);\\n node.setAttribute('aria-pressed', String(state.selected === segment.id));\\n node.setAttribute('aria-label', `${segment.kept ? 'Kept' : 'Removed'} part from ${formatTime(segment.start)} to ${formatTime(segment.end)}`);\\n node.style.left = `${fraction(segment.start) * 100}%`; node.style.width = `${(fraction(segment.end) - fraction(segment.start)) * 100}%`;\\n node.addEventListener('click', event => { state.selected = state.selected === segment.id ? null : segment.id; seekTo(timeAt(event.clientX)); renderTimeline(); updateButtons(); });\\n segmentsNode.append(node);\\n if (index > 0) {\\n const handle = document.createElement('button');\\n handle.type = 'button'; handle.className = 'sr-handle'; handle.dataset.index = String(index);\\n handle.setAttribute('aria-label', `Move the cut at ${formatTime(segment.start)}`);\\n handle.style.left = `${fraction(segment.start) * 100}%`;\\n handle.addEventListener('pointerdown', beginDrag);\\n segmentsNode.append(handle);\\n }\\n });\\n say('kept', isWhole(state.segments) ? `${formatTime(state.duration)} kept` : `${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)} kept`);\\n}\\nfunction timeAt(clientX) {\\n const box = timeline.getBoundingClientRect();\\n return box.width > 0 ? Math.max(0, Math.min(state.duration, (clientX - box.left) / box.width * state.duration)) : 0;\\n}\\nfunction beginDrag(event) {\\n event.preventDefault(); event.stopPropagation();\\n const index = Number(event.currentTarget.dataset.index);\\n state.dragging = { index, before: state.segments, handle: event.currentTarget, pointerId: event.pointerId };\\n event.currentTarget.setPointerCapture(event.pointerId);\\n event.currentTarget.dataset.active = 'true';\\n event.currentTarget.addEventListener('pointermove', continueDrag);\\n event.currentTarget.addEventListener('pointerup', endDrag);\\n event.currentTarget.addEventListener('pointercancel', endDrag);\\n}\\nfunction continueDrag(event) {\\n const drag = state.dragging; if (!drag) return;\\n const next = moveBoundary(state.segments, drag.index, timeAt(event.clientX));\\n if (next === state.segments) return;\\n state.segments = next;\\n const boundary = next[drag.index].start;\\n drag.handle.style.left = `${fraction(boundary) * 100}%`;\\n const left = segmentsNode.querySelector(`.sr-seg[data-id=\\\"${next[drag.index - 1].id}\\\"]`), right = segmentsNode.querySelector(`.sr-seg[data-id=\\\"${next[drag.index].id}\\\"]`);\\n if (left) left.style.width = `${(fraction(boundary) - fraction(next[drag.index - 1].start)) * 100}%`;\\n if (right) { right.style.left = `${fraction(boundary) * 100}%`; right.style.width = `${(fraction(next[drag.index].end) - fraction(boundary)) * 100}%`; }\\n seekTo(boundary, { quiet: true });\\n}\\nfunction endDrag(event) {\\n const drag = state.dragging; if (!drag) return;\\n state.dragging = null;\\n drag.handle.dataset.active = 'false';\\n drag.handle.removeEventListener('pointermove', continueDrag);\\n drag.handle.removeEventListener('pointerup', endDrag);\\n drag.handle.removeEventListener('pointercancel', endDrag);\\n try { drag.handle.releasePointerCapture(drag.pointerId); } catch (error) { /* released */ }\\n if (drag.before !== state.segments) { state.undo.push(drag.before); if (state.undo.length > 100) state.undo.shift(); }\\n renderTimeline(); updateButtons();\\n}\\ntimeline.addEventListener('pointerdown', event => { if (event.target === timeline || event.target === segmentsNode) seekTo(timeAt(event.clientX)); });\\nfunction seekTo(time, options) {\\n player.currentTime = Math.max(0, Math.min(state.duration, time));\\n if (!(options && options.quiet)) updateTime();\\n}\\nfunction updateTime() {\\n const time = player.currentTime || 0;\\n playhead.style.transform = `translateX(${fraction(time) * timeline.clientWidth}px)`;\\n say('time', `${formatTime(time)} / ${formatTime(state.duration)}`);\\n}\\nplayer.addEventListener('timeupdate', () => {\\n if (state.phase !== 'editor') return;\\n if ($('skip').checked && !player.paused) {\\n const next = nextKeptTime(state.segments, player.currentTime);\\n if (next === null) { player.pause(); }\\n else if (next !== player.currentTime) { player.currentTime = next; }\\n }\\n updateTime();\\n});\\nplayer.addEventListener('play', () => { if ($('skip').checked) { const next = nextKeptTime(state.segments, player.currentTime); if (next === null) player.currentTime = firstKeptTime(state.segments); else if (next !== player.currentTime) player.currentTime = next; } $('play').textContent = 'Pause'; });\\nplayer.addEventListener('pause', () => { $('play').textContent = 'Play'; });\\nplayer.addEventListener('ended', () => { $('play').textContent = 'Play'; });\\n$('play').addEventListener('click', () => { if (player.paused) player.play().catch(error => say('editor-status', message(error))); else player.pause(); });\\nfunction apply(next) {\\n if (next === state.segments) return;\\n state.undo.push(state.segments); if (state.undo.length > 100) state.undo.shift();\\n state.segments = next;\\n renderTimeline(); updateButtons();\\n}\\n$('cut').addEventListener('click', () => { apply(splitAt(state.segments, player.currentTime)); });\\n$('toggle').addEventListener('click', () => { if (state.selected !== null) apply(toggleKept(state.segments, state.selected)); });\\n$('undo').addEventListener('click', () => { const previous = state.undo.pop(); if (previous) { state.segments = previous; if (!previous.some(segment => segment.id === state.selected)) state.selected = null; renderTimeline(); updateButtons(); } });\\ntimeline.addEventListener('keydown', event => {\\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); seekTo((player.currentTime || 0) + (event.key === 'ArrowLeft' ? -0.5 : 0.5) * (event.shiftKey ? 10 : 1)); }\\n else if (event.key === 'c' || event.key === 'C') { event.preventDefault(); $('cut').click(); }\\n else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); $('toggle').click(); }\\n else if (event.key === ' ') { event.preventDefault(); $('play').click(); }\\n});\\nfunction updateButtons() {\\n const selected = state.segments.find(segment => segment.id === state.selected);\\n $('toggle').disabled = !selected; $('toggle').dataset.off = String(!selected);\\n $('toggle').textContent = selected && !selected.kept ? 'Keep part' : 'Remove part';\\n $('undo').disabled = !state.undo.length; $('undo').dataset.off = String(!state.undo.length);\\n const whole = isWhole(state.segments), nothing = keepRanges(state.segments).length === 0;\\n $('save').textContent = whole ? 'Use the whole recording' : 'Save the cut';\\n $('save').disabled = nothing; $('save').dataset.off = String(nothing);\\n}\\nwindow.addEventListener('resize', () => { if (state.phase === 'editor') updateTime(); });\\n\\n// ── Save ───────────────────────────────────────────────────────────────────\\n$('save').addEventListener('click', async () => {\\n const source = state.source; if (!source) return;\\n const keep = keepRanges(state.segments);\\n if (!keep.length) { say('editor-status', 'Keep at least one part.'); return; }\\n busy(true); player.pause();\\n try {\\n let video = source;\\n if (!isWhole(state.segments)) {\\n say('editor-status', 'Cutting on this device…');\\n let watching = true;\\n const watch = async () => { while (watching) { await sleep(700); if (!watching) break; try { const status = await StillMade.desktop('record.status'); if (status.cutting) say('editor-status', `Cutting on this device… ${Math.round(status.cutting.progress * 100)}%`); } catch (error) { /* next tick */ } } };\\n watch();\\n try { video = (await StillMade.desktop('record.cut', { source, keep })).asset; }\\n finally { watching = false; }\\n }\\n say('editor-status', 'Passing the result on…');\\n await StillMade.run({ video, original: source });\\n state.lastSaved = video;\\n say('done-text', isWhole(state.segments) ? `The whole recording (${formatTime(state.duration)}) is the result.` : `Saved ${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)}. The uncut recording is kept beside it.`);\\n show('done');\\n } catch (error) { say('editor-status', message(error)); }\\n finally { busy(false); }\\n});\\n$('edit-again').addEventListener('click', () => { if (state.source) { show('editor'); say('editor-status', ''); } });\\n\\n// ── Input: a video from an earlier Step opens straight in the editor ──────\\n// onInput first fires once the view is ready, which is also the earliest moment a host call can be made.\\nStillMade.onInput(async input => {\\n if (!state.device && !state.deviceAsked) { state.deviceAsked = true; loadDevice(); }\\n const video = input && input.video;\\n if (!video || state.source || state.phase !== 'ready') return;\\n busy(true);\\n try { await openEditor(video); say('editor-status', 'Editing the video this Block received. Record again to replace it.'); }\\n catch (error) { say('ready-status', message(error)); }\\n finally { busy(false); }\\n});\\n\\n})();\\n\"\n}"
216
+ "upstream/0/src/view.json": "{\n \"runtime\": \"frame\",\n \"html\": \"<main class=\\\"sm-block sr\\\">\\n <section class=\\\"sm-stage sr-stage\\\" id=\\\"stage\\\" aria-live=\\\"polite\\\">\\n <video id=\\\"player\\\" class=\\\"sr-player\\\" playsinline preload=\\\"metadata\\\" hidden></video>\\n <div class=\\\"sm-empty\\\" id=\\\"empty\\\">\\n <span class=\\\"sm-glyph\\\" data-glyph=\\\"video\\\" aria-hidden=\\\"true\\\"></span>\\n <p>Nothing recorded yet</p>\\n </div>\\n <div class=\\\"sr-live\\\" id=\\\"meter\\\" hidden>\\n <span class=\\\"sr-dot\\\" aria-hidden=\\\"true\\\"></span>\\n <strong id=\\\"timer\\\">0:00.0</strong>\\n <span id=\\\"size\\\">0 KB</span>\\n </div>\\n <div id=\\\"timeline\\\" class=\\\"sr-timeline\\\" role=\\\"group\\\" aria-label=\\\"Recording timeline\\\" tabindex=\\\"0\\\" hidden>\\n <div id=\\\"segments\\\" class=\\\"sr-segments\\\"></div>\\n <div id=\\\"playhead\\\" class=\\\"sr-playhead\\\" aria-hidden=\\\"true\\\"></div>\\n </div>\\n </section>\\n <fieldset class=\\\"sm-choices\\\" data-compact id=\\\"sound\\\">\\n <legend>Sound</legend>\\n <label><input id=\\\"mic\\\" type=\\\"checkbox\\\" data-stillmade-share=\\\"microphone\\\"><span class=\\\"sm-glyph\\\" data-glyph=\\\"audio\\\" aria-hidden=\\\"true\\\"></span><span>Microphone</span></label>\\n <label><input id=\\\"system\\\" type=\\\"checkbox\\\" data-stillmade-share=\\\"computerSound\\\"><span>Computer sound</span></label>\\n </fieldset>\\n <div class=\\\"sm-actions\\\" id=\\\"tools\\\" hidden>\\n <button type=\\\"button\\\" class=\\\"sm-icon\\\" id=\\\"play\\\" aria-label=\\\"Play\\\" title=\\\"Play\\\"><span class=\\\"sm-glyph\\\" data-glyph=\\\"play\\\" aria-hidden=\\\"true\\\"></span></button>\\n <span class=\\\"sr-time\\\" id=\\\"time\\\">0:00.0 / 0:00.0</span>\\n <button type=\\\"button\\\" class=\\\"sm-quiet\\\" id=\\\"cut\\\">Cut here</button>\\n <button type=\\\"button\\\" class=\\\"sm-quiet\\\" id=\\\"toggle\\\" disabled>Remove part</button>\\n <button type=\\\"button\\\" class=\\\"sm-quiet\\\" id=\\\"undo\\\" disabled>Undo</button>\\n <span class=\\\"sr-time\\\" id=\\\"kept\\\"></span>\\n </div>\\n <div class=\\\"sm-actions\\\">\\n <button type=\\\"button\\\" class=\\\"sm-primary\\\" id=\\\"action\\\">Record screen</button>\\n <button type=\\\"button\\\" class=\\\"sm-quiet\\\" id=\\\"secondary\\\" hidden>Record again</button>\\n <p class=\\\"sm-status\\\" id=\\\"status\\\" role=\\\"status\\\"></p>\\n <details class=\\\"sm-hint\\\" id=\\\"why\\\" hidden><summary aria-label=\\\"Why\\\" title=\\\"Why\\\"></summary><p id=\\\"why-text\\\"></p></details>\\n </div>\\n <details class=\\\"sm-more\\\" id=\\\"more\\\">\\n <summary>More</summary>\\n <div class=\\\"sm-settings\\\">\\n <fieldset class=\\\"sm-choices\\\" data-segmented id=\\\"resolution\\\" data-stillmade-share=\\\"resolution\\\">\\n <legend>Size</legend>\\n <label><input type=\\\"radio\\\" name=\\\"resolution\\\" value=\\\"1080p\\\" checked><span>1080p</span></label>\\n <label><input type=\\\"radio\\\" name=\\\"resolution\\\" value=\\\"source\\\"><span>Full size</span></label>\\n </fieldset>\\n <fieldset class=\\\"sm-choices\\\" data-compact id=\\\"playback\\\" hidden>\\n <legend>Playback</legend>\\n <label><input id=\\\"skip\\\" type=\\\"checkbox\\\" checked data-stillmade-local><span>Skip removed parts</span></label>\\n </fieldset>\\n </div>\\n </details>\\n</main>\\n\",\n \"css\": \"/* The Block kit (injected by the host) carries the layout; this file only draws\\n what the kit has no piece for: the recording meter and the cut timeline. */\\n.sr [hidden] { display: none !important; }\\n.sr-stage { display: grid; grid-template-rows: minmax(0, 1fr) auto; align-items: stretch; justify-items: stretch; gap: var(--s-2, 8px); }\\n.sr-stage > .sm-empty, .sr-live { align-self: center; justify-self: center; }\\n.sr-player { width: 100%; height: 100%; min-height: 0; max-height: 100%; object-fit: contain; border-radius: var(--r-2, 4px); background: var(--bg-inset); }\\n#sound { --sm-tile: 150px; }\\n.sr-live { display: flex; align-items: center; gap: var(--s-3, 12px); font-variant-numeric: tabular-nums; }\\n.sr-live strong { font-size: 1.6rem; font-weight: 500; }\\n.sr-live span:last-child { color: var(--fg-muted); }\\n.sr-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--attention-error, var(--signal)); }\\n.sr-time { color: var(--fg-muted); font-size: var(--fs-body-s, .875rem); font-variant-numeric: tabular-nums; }\\n/* Kept parts are solid, removed parts hatched, the chosen part outlined. */\\n.sr-timeline { position: relative; height: 48px; border-radius: var(--r-3, 8px); background: var(--bg-elev-1); border: 1px solid var(--border-tok); overflow: hidden; touch-action: none; user-select: none; }\\n.sr-timeline:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }\\n.sr-segments { position: absolute; inset: 0; }\\n.sr-seg { position: absolute; top: 0; bottom: 0; border: 0; padding: 0; background: var(--fg-muted); opacity: .5; cursor: pointer; }\\n.sr-seg[data-kept=\\\"false\\\"] { background: repeating-linear-gradient(135deg, transparent 0 6px, var(--border-strong) 6px 8px); opacity: 1; }\\n.sr-seg[aria-pressed=\\\"true\\\"] { box-shadow: inset 0 0 0 2px var(--fg); opacity: 1; }\\n.sr-seg:focus-visible { outline: 2px solid var(--signal); outline-offset: -2px; }\\n.sr-handle { position: absolute; top: 0; bottom: 0; width: 16px; margin-left: -8px; cursor: ew-resize; background: transparent; border: 0; padding: 0; }\\n.sr-handle::after { content: \\\"\\\"; position: absolute; top: 4px; bottom: 4px; left: 6px; width: 4px; border-radius: 2px; background: var(--bg); box-shadow: 0 0 0 1px var(--fg); }\\n.sr-handle:hover::after, .sr-handle[data-active=\\\"true\\\"]::after { background: var(--fg); }\\n.sr-playhead { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--fg); pointer-events: none; }\\n\",\n \"javascript\": \"(()=>{\\n\\\"use strict\\\";\\n// The cut editor's model: the recording is an ordered list of segments that\\n// together cover it, each kept or removed. A cut splits the segment under the\\n// playhead, removing a segment drops it from the result, and dragging a\\n// boundary trims both neighbours. Every change returns a new array, so undo\\n// keeps the earlier ones. Times are seconds of the source recording.\\nconst MIN_SEGMENT = 0.1;\\nlet nextSegmentId = 1;\\nfunction createSegments(duration) {\\n nextSegmentId = 1;\\n return [{ id: nextSegmentId++, start: 0, end: duration, kept: true }];\\n}\\nfunction segmentAt(segments, time) {\\n const found = segments.find(segment => time >= segment.start && time < segment.end);\\n if (found) return found;\\n const last = segments[segments.length - 1];\\n return last && time >= last.end ? last : null;\\n}\\nfunction splitAt(segments, time) {\\n const index = segments.findIndex(segment => time > segment.start + MIN_SEGMENT && time < segment.end - MIN_SEGMENT);\\n if (index < 0) return segments;\\n const segment = segments[index];\\n return [...segments.slice(0, index), { ...segment, end: time }, { id: nextSegmentId++, start: time, end: segment.end, kept: segment.kept }, ...segments.slice(index + 1)];\\n}\\nfunction toggleKept(segments, id) {\\n const index = segments.findIndex(segment => segment.id === id);\\n if (index < 0) return segments;\\n const next = segments.slice();\\n next[index] = { ...next[index], kept: !next[index].kept };\\n return next;\\n}\\n/** Moves the boundary before segments[index], clamped so both neighbours keep a minimum length. */\\nfunction moveBoundary(segments, index, time) {\\n if (index < 1 || index >= segments.length) return segments;\\n const left = segments[index - 1], right = segments[index];\\n const clamped = Math.min(right.end - MIN_SEGMENT, Math.max(left.start + MIN_SEGMENT, time));\\n if (clamped === left.end) return segments;\\n const next = segments.slice();\\n next[index - 1] = { ...left, end: clamped };\\n next[index] = { ...right, start: clamped };\\n return next;\\n}\\n/** Trims the recording's start or end to `time`: the trimmed part becomes a removed segment. */\\nfunction trimEdge(segments, edge, time) {\\n if (edge === 'start') {\\n if (!segments[0].kept) return moveBoundary(segments, 1, time);\\n const split = splitAt(segments, time);\\n return split === segments ? segments : toggleKept(split, split[0].id);\\n }\\n const last = segments[segments.length - 1];\\n if (!last.kept) return moveBoundary(segments, segments.length - 1, time);\\n const split = splitAt(segments, time);\\n return split === segments ? segments : toggleKept(split, split[split.length - 1].id);\\n}\\n/** The kept ranges in order, neighbours merged: what record.cut keeps. */\\nfunction keepRanges(segments) {\\n const ranges = [];\\n for (const segment of segments) {\\n if (!segment.kept) continue;\\n const last = ranges[ranges.length - 1];\\n if (last && Math.abs(last.end - segment.start) < 1e-6) last.end = segment.end;\\n else ranges.push({ start: segment.start, end: segment.end });\\n }\\n return ranges;\\n}\\nfunction keptDuration(segments) {\\n return segments.reduce((sum, segment) => segment.kept ? sum + segment.end - segment.start : sum, 0);\\n}\\nfunction isWhole(segments) {\\n return segments.every(segment => segment.kept);\\n}\\n/** Where playback continues from `time` when removed parts are skipped: `time` itself when kept, the next kept start, or null when nothing kept follows. */\\nfunction nextKeptTime(segments, time) {\\n const segment = segmentAt(segments, time);\\n if (!segment || segment.kept) return time;\\n const later = segments.find(candidate => candidate.kept && candidate.start >= segment.end);\\n return later ? later.start : null;\\n}\\nfunction firstKeptTime(segments) {\\n const segment = segments.find(candidate => candidate.kept);\\n return segment ? segment.start : 0;\\n}\\n/** Seconds as m:ss.d */\\nfunction formatTime(seconds) {\\n const total = Math.max(0, Math.round((Number(seconds) || 0) * 10) / 10);\\n const minutes = Math.floor(total / 60), whole = Math.floor(total % 60), tenth = Math.round((total - Math.floor(total)) * 10) % 10;\\n return `${minutes}:${String(whole).padStart(2, '0')}.${tenth}`;\\n}\\nfunction formatBytes(bytes) {\\n const value = Number(bytes) || 0;\\n return value >= 1024 * 1024 * 1024 ? `${(value / 1024 / 1024 / 1024).toFixed(2)} GB` : value >= 1024 * 1024 ? `${Math.round(value / 1024 / 1024)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;\\n}\\n\\n// The Screen Recorder interface on the Block kit: the stage shows the recording\\n// (a meter while recording, the player and the cut timeline after), one button\\n// names the next step, and the status beside it says the state in a few words\\n// with the reason behind the (i). The host records; this view only asks.\\n// Built into src/view.js with the cut model.\\nconst $ = id => document.getElementById(id);\\nconst player = $('player'), timeline = $('timeline'), segmentsNode = $('segments'), playhead = $('playhead');\\nconst state = { phase: 'ready', source: null, segments: [], undo: [], selected: null, duration: 0, previewUrl: null, dragging: null, poll: null, busy: false, device: null, deviceAsked: false, capacity: null };\\n\\nconst say = (id, text) => { $(id).textContent = text || ''; };\\nconst message = error => String(error && error.message || error || 'Something went wrong.');\\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\\n// A few words beside the action, a state dot, and the reason behind the (i).\\nfunction status(text, tone, why) {\\n const node = $('status');\\n node.textContent = text || '';\\n if (tone) node.dataset.state = tone; else delete node.dataset.state;\\n $('why').hidden = !why; $('why').open = false; say('why-text', why || '');\\n}\\nconst busy = value => { state.busy = value; for (const id of ['action', 'secondary', 'play', 'cut', 'toggle', 'undo']) $(id).disabled = value || ($(id).dataset.off === 'true'); };\\n\\n// ── Phases: what the stage, the settings and the action row show ──────────\\nconst PHASES = {\\n ready: { empty: true, meter: false, player: false, timeline: false, tools: false, sound: true, size: true, playback: false, action: 'Record screen', secondary: '' },\\n live: { empty: false, meter: true, player: false, timeline: false, tools: false, sound: false, size: false, playback: false, action: 'Stop and edit', secondary: 'Discard' },\\n editor: { empty: false, meter: false, player: true, timeline: true, tools: true, sound: false, size: false, playback: true, action: '', secondary: 'Record again' },\\n done: { empty: false, meter: false, player: true, timeline: false, tools: false, sound: false, size: false, playback: false, action: 'Edit again', secondary: 'Record again' },\\n};\\nfunction show(phase) {\\n state.phase = phase;\\n const p = PHASES[phase];\\n $('empty').hidden = !p.empty; $('meter').hidden = !p.meter; player.hidden = !p.player; timeline.hidden = !p.timeline; $('tools').hidden = !p.tools;\\n $('sound').hidden = !p.sound; $('resolution').hidden = !p.size; $('playback').hidden = !p.playback; $('more').hidden = !(p.size || p.playback);\\n $('secondary').hidden = !p.secondary; if (p.secondary) say('secondary', p.secondary);\\n if (p.action) say('action', p.action);\\n $('action').dataset.off = 'false';\\n if (phase === 'editor') updateButtons(); else if (phase === 'ready') showCapacity();\\n $('action').disabled = state.busy || $('action').dataset.off === 'true';\\n}\\n$('action').addEventListener('click', () => { ({ ready: start, live: stop, editor: save, done: editAgain })[state.phase](); });\\n$('secondary').addEventListener('click', () => { if (state.phase === 'live') discard(); else recordAgain(); });\\n\\n// ── What fits here ─────────────────────────────────────────────────────────\\nconst span = seconds => seconds >= 3600 ? Math.round(seconds / 3600) + ' h' : Math.max(1, Math.round(seconds / 60)) + ' min';\\nfunction describeDevice(device) {\\n if (!device.recorder) return { text: 'This browser cannot record', tone: 'error', why: 'Use a current desktop browser or StillMade Desktop.', off: true };\\n const notes = [];\\n if (device.cut === 'playback') notes.push('A cut here takes as long as the part you keep.');\\n if (device.cut === 'none') notes.push('This browser cannot cut; use the whole recording, or cut it in StillMade Desktop.');\\n if (device.platform === 'desktop') return { text: 'Records to disk, up to ' + span(device.budget.seconds), tone: '', why: notes.join(' ') };\\n if (device.budget.seconds < 60) return { text: 'Less than a minute fits here', tone: 'error', why: ['Free up storage, or use StillMade Desktop, which records to disk.', ...notes].join(' ') };\\n const fit = span(device.budget.seconds);\\n return { text: 'About ' + fit + ' fit here', tone: device.budget.seconds < 300 ? 'waiting' : '', why: [fit + ' at 1080p' + (device.budget.bytes ? ' (' + formatBytes(device.budget.bytes) + ')' : '') + ' fit on this device. StillMade Desktop records longer.', ...notes].join(' ') };\\n}\\nasync function loadDevice() {\\n let problem = null;\\n try { state.device = await StillMade.desktop('device.info'); } catch (error) { state.device = null; problem = message(error); }\\n state.capacity = problem ? { text: 'Could not check what fits here', tone: 'waiting', why: problem } : describeDevice(state.device);\\n showCapacity();\\n}\\nfunction showCapacity() {\\n if (state.phase !== 'ready' || !state.capacity) return;\\n const capacity = state.capacity;\\n status(capacity.text, capacity.tone, capacity.why);\\n $('action').dataset.off = String(!!capacity.off); $('action').disabled = state.busy || !!capacity.off;\\n}\\nconst ENDED = { sharing: 'Sharing ended', time: 'Time limit reached', size: 'Out of space here', disk: 'Could not write to disk' };\\nfunction describeLive(live) {\\n const limits = live.limits || {};\\n if (limits.seconds) {\\n const left = Math.max(0, limits.seconds - (live.elapsed || 0) / 1000);\\n if (left <= limits.seconds * 0.2) return { text: formatTime(left) + ' left', why: 'The recording stops at ' + span(limits.seconds) + ' and is kept up to that point.' };\\n }\\n if (limits.bytes) {\\n const left = Math.max(0, limits.bytes - (live.bytes || 0));\\n if (left <= limits.bytes * 0.2) return { text: formatBytes(left) + ' left', why: 'The recording stops when the space here runs out and is kept up to that point.' };\\n }\\n return null;\\n}\\n\\n// ── Record ─────────────────────────────────────────────────────────────────\\nasync function pollStatus() {\\n if (state.phase !== 'live') return;\\n try {\\n const live = await StillMade.desktop('record.status');\\n if (state.phase !== 'live') return;\\n if (live.active) {\\n say('timer', formatTime((live.elapsed || 0) / 1000));\\n say('size', formatBytes(live.bytes));\\n if (live.ended) status(ENDED[live.stoppedBy] || 'Recording stopped', 'waiting', (live.message || 'The recording stopped.') + ' Press Stop and edit to keep it.');\\n else { const warning = describeLive(live); if (warning) status(warning.text, 'waiting', warning.why); else status('Recording', 'loading'); }\\n }\\n } catch (error) { status(message(error), 'error'); }\\n state.poll = setTimeout(pollStatus, 1000);\\n}\\nasync function start() {\\n busy(true);\\n status('Press Start above', 'waiting', 'Then choose the screen or window to record. Nothing is captured before that.');\\n try {\\n const picked = document.querySelector('input[name=\\\"resolution\\\"]:checked');\\n await StillMade.desktop('record.start', { kind: 'screen', microphone: $('mic').checked, systemAudio: $('system').checked, resolution: picked ? picked.value : '1080p' });\\n say('timer', '0:00.0'); say('size', '0 KB');\\n show('live'); status('Recording', 'loading'); pollStatus();\\n } catch (error) { status(message(error), 'error'); }\\n finally { busy(false); }\\n}\\nasync function stop() {\\n busy(true); clearTimeout(state.poll);\\n status('Saving on this device', 'loading');\\n try {\\n const result = await StillMade.desktop('record.stop');\\n const asset = { ...result.asset };\\n for (const key of ['duration', 'width', 'height']) if (result[key] && !asset[key]) asset[key] = result[key];\\n await openEditor(asset);\\n if (result.stoppedBy) status(ENDED[result.stoppedBy] || 'Recording stopped', 'waiting', result.message); else status('');\\n } catch (error) { status(message(error), 'error'); pollStatus(); }\\n finally { busy(false); }\\n}\\nasync function discard() {\\n busy(true); clearTimeout(state.poll);\\n try { await StillMade.desktop('record.cancel'); } catch (error) { /* already gone */ }\\n show('ready'); busy(false);\\n}\\nfunction recordAgain() { closePreview(); state.source = null; show('ready'); loadDevice(); }\\n\\n// ── Editor ─────────────────────────────────────────────────────────────────\\nfunction closePreview() {\\n player.pause();\\n if (state.previewUrl) { try { StillMade.releaseMedia(state.previewUrl); } catch (error) { /* released */ } state.previewUrl = null; }\\n player.removeAttribute('src'); player.load();\\n}\\nasync function openEditor(source) {\\n closePreview();\\n state.source = source; state.selected = null; state.undo = [];\\n const { url } = await StillMade.previewMedia(source);\\n state.previewUrl = url;\\n const duration = await new Promise((resolve, reject) => {\\n const done = () => { player.onloadedmetadata = null; player.onerror = null; resolve(Number.isFinite(player.duration) && player.duration > 0 ? player.duration : Number(source.duration) || 0); };\\n player.onloadedmetadata = done;\\n player.onerror = () => reject(new Error('This recording could not be opened for editing.'));\\n player.src = url;\\n });\\n if (!(duration > 0)) throw new Error('The recording has no length to edit.');\\n state.duration = duration;\\n state.segments = createSegments(duration);\\n show('editor'); status('');\\n renderTimeline(); updateTime(); updateButtons();\\n}\\nconst fraction = time => Math.max(0, Math.min(1, time / state.duration));\\nfunction renderTimeline() {\\n segmentsNode.replaceChildren();\\n state.segments.forEach((segment, index) => {\\n const node = document.createElement('button');\\n node.type = 'button'; node.className = 'sr-seg'; node.dataset.id = String(segment.id); node.dataset.kept = String(segment.kept);\\n node.setAttribute('aria-pressed', String(state.selected === segment.id));\\n node.setAttribute('aria-label', `${segment.kept ? 'Kept' : 'Removed'} part from ${formatTime(segment.start)} to ${formatTime(segment.end)}`);\\n node.style.left = `${fraction(segment.start) * 100}%`; node.style.width = `${(fraction(segment.end) - fraction(segment.start)) * 100}%`;\\n node.addEventListener('click', event => { state.selected = state.selected === segment.id ? null : segment.id; seekTo(timeAt(event.clientX)); renderTimeline(); updateButtons(); });\\n segmentsNode.append(node);\\n if (index > 0) {\\n const handle = document.createElement('button');\\n handle.type = 'button'; handle.className = 'sr-handle'; handle.dataset.index = String(index);\\n handle.setAttribute('aria-label', `Move the cut at ${formatTime(segment.start)}`);\\n handle.style.left = `${fraction(segment.start) * 100}%`;\\n handle.addEventListener('pointerdown', beginDrag);\\n segmentsNode.append(handle);\\n }\\n });\\n say('kept', isWhole(state.segments) ? `${formatTime(state.duration)} kept` : `${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)} kept`);\\n}\\nfunction timeAt(clientX) {\\n const box = timeline.getBoundingClientRect();\\n return box.width > 0 ? Math.max(0, Math.min(state.duration, (clientX - box.left) / box.width * state.duration)) : 0;\\n}\\nfunction beginDrag(event) {\\n event.preventDefault(); event.stopPropagation();\\n const index = Number(event.currentTarget.dataset.index);\\n state.dragging = { index, before: state.segments, handle: event.currentTarget, pointerId: event.pointerId };\\n event.currentTarget.setPointerCapture(event.pointerId);\\n event.currentTarget.dataset.active = 'true';\\n event.currentTarget.addEventListener('pointermove', continueDrag);\\n event.currentTarget.addEventListener('pointerup', endDrag);\\n event.currentTarget.addEventListener('pointercancel', endDrag);\\n}\\nfunction continueDrag(event) {\\n const drag = state.dragging; if (!drag) return;\\n const next = moveBoundary(state.segments, drag.index, timeAt(event.clientX));\\n if (next === state.segments) return;\\n state.segments = next;\\n const boundary = next[drag.index].start;\\n drag.handle.style.left = `${fraction(boundary) * 100}%`;\\n const left = segmentsNode.querySelector(`.sr-seg[data-id=\\\"${next[drag.index - 1].id}\\\"]`), right = segmentsNode.querySelector(`.sr-seg[data-id=\\\"${next[drag.index].id}\\\"]`);\\n if (left) left.style.width = `${(fraction(boundary) - fraction(next[drag.index - 1].start)) * 100}%`;\\n if (right) { right.style.left = `${fraction(boundary) * 100}%`; right.style.width = `${(fraction(next[drag.index].end) - fraction(boundary)) * 100}%`; }\\n seekTo(boundary, { quiet: true });\\n}\\nfunction endDrag(event) {\\n const drag = state.dragging; if (!drag) return;\\n state.dragging = null;\\n drag.handle.dataset.active = 'false';\\n drag.handle.removeEventListener('pointermove', continueDrag);\\n drag.handle.removeEventListener('pointerup', endDrag);\\n drag.handle.removeEventListener('pointercancel', endDrag);\\n try { drag.handle.releasePointerCapture(drag.pointerId); } catch (error) { /* released */ }\\n if (drag.before !== state.segments) { state.undo.push(drag.before); if (state.undo.length > 100) state.undo.shift(); }\\n renderTimeline(); updateButtons();\\n}\\ntimeline.addEventListener('pointerdown', event => { if (event.target === timeline || event.target === segmentsNode) seekTo(timeAt(event.clientX)); });\\nfunction seekTo(time, options) {\\n player.currentTime = Math.max(0, Math.min(state.duration, time));\\n if (!(options && options.quiet)) updateTime();\\n}\\nfunction updateTime() {\\n const time = player.currentTime || 0;\\n playhead.style.transform = `translateX(${fraction(time) * timeline.clientWidth}px)`;\\n say('time', `${formatTime(time)} / ${formatTime(state.duration)}`);\\n}\\nconst playGlyph = paused => { const glyph = $('play').querySelector('.sm-glyph'); glyph.dataset.glyph = paused ? 'play' : 'pause'; $('play').setAttribute('aria-label', paused ? 'Play' : 'Pause'); $('play').title = paused ? 'Play' : 'Pause'; };\\nplayer.addEventListener('timeupdate', () => {\\n if (state.phase !== 'editor') return;\\n if ($('skip').checked && !player.paused) {\\n const next = nextKeptTime(state.segments, player.currentTime);\\n if (next === null) { player.pause(); }\\n else if (next !== player.currentTime) { player.currentTime = next; }\\n }\\n updateTime();\\n});\\nplayer.addEventListener('play', () => { if ($('skip').checked) { const next = nextKeptTime(state.segments, player.currentTime); if (next === null) player.currentTime = firstKeptTime(state.segments); else if (next !== player.currentTime) player.currentTime = next; } playGlyph(false); });\\nplayer.addEventListener('pause', () => playGlyph(true));\\nplayer.addEventListener('ended', () => playGlyph(true));\\n$('play').addEventListener('click', () => { if (player.paused) player.play().catch(error => status(message(error), 'error')); else player.pause(); });\\nfunction apply(next) {\\n if (next === state.segments) return;\\n state.undo.push(state.segments); if (state.undo.length > 100) state.undo.shift();\\n state.segments = next;\\n renderTimeline(); updateButtons();\\n}\\n$('cut').addEventListener('click', () => { apply(splitAt(state.segments, player.currentTime)); });\\n$('toggle').addEventListener('click', () => { if (state.selected !== null) apply(toggleKept(state.segments, state.selected)); });\\n$('undo').addEventListener('click', () => { const previous = state.undo.pop(); if (previous) { state.segments = previous; if (!previous.some(segment => segment.id === state.selected)) state.selected = null; renderTimeline(); updateButtons(); } });\\ntimeline.addEventListener('keydown', event => {\\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); seekTo((player.currentTime || 0) + (event.key === 'ArrowLeft' ? -0.5 : 0.5) * (event.shiftKey ? 10 : 1)); }\\n else if (event.key === 'c' || event.key === 'C') { event.preventDefault(); $('cut').click(); }\\n else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); $('toggle').click(); }\\n else if (event.key === ' ') { event.preventDefault(); $('play').click(); }\\n});\\nfunction updateButtons() {\\n const selected = state.segments.find(segment => segment.id === state.selected);\\n $('toggle').disabled = state.busy || !selected; $('toggle').dataset.off = String(!selected);\\n say('toggle', selected && !selected.kept ? 'Keep part' : 'Remove part');\\n $('undo').disabled = state.busy || !state.undo.length; $('undo').dataset.off = String(!state.undo.length);\\n const whole = isWhole(state.segments), nothing = keepRanges(state.segments).length === 0;\\n say('action', whole ? 'Use the whole recording' : 'Save the cut');\\n $('action').disabled = state.busy || nothing; $('action').dataset.off = String(nothing);\\n}\\nwindow.addEventListener('resize', () => { if (state.phase === 'editor') updateTime(); });\\n\\n// ── Save ───────────────────────────────────────────────────────────────────\\nasync function save() {\\n const source = state.source; if (!source) return;\\n const keep = keepRanges(state.segments);\\n if (!keep.length) { status('Keep at least one part', 'error'); return; }\\n busy(true); player.pause();\\n try {\\n let video = source;\\n if (!isWhole(state.segments)) {\\n status('Cutting on this device', 'loading');\\n let watching = true;\\n const watch = async () => { while (watching) { await sleep(700); if (!watching) break; try { const live = await StillMade.desktop('record.status'); if (live.cutting) status(`Cutting ${Math.round(live.cutting.progress * 100)}%`, 'loading'); } catch (error) { /* next tick */ } } };\\n watch();\\n try { video = (await StillMade.desktop('record.cut', { source, keep })).asset; }\\n finally { watching = false; }\\n }\\n status('Passing it on', 'loading');\\n await StillMade.run({ video, original: source });\\n show('done');\\n if (isWhole(state.segments)) status(`Whole recording, ${formatTime(state.duration)}`, 'success');\\n else status(`Saved ${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)}`, 'success', 'The uncut recording is passed on beside it.');\\n } catch (error) { status(message(error), 'error'); }\\n finally { busy(false); }\\n}\\nfunction editAgain() { if (state.source) { show('editor'); status(''); } }\\n\\n// ── Input: a video from an earlier Step opens straight in the editor ──────\\n// onInput first fires once the view is ready, which is also the earliest moment a host call can be made.\\nStillMade.onInput(async input => {\\n if (!state.deviceAsked) { state.deviceAsked = true; loadDevice(); }\\n const video = input && input.video;\\n if (!video || state.source || state.phase !== 'ready') return;\\n busy(true);\\n try { await openEditor(video); status('Video from an earlier Step', '', 'Record again to replace it.'); }\\n catch (error) { status(message(error), 'error'); }\\n finally { busy(false); }\\n});\\n\\n})();\\n\"\n}"
217
217
  },
218
218
  "lineage": {
219
219
  "parents": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stillmade-sdk",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "StillMade Block SDK, CLI and examples",
5
5
  "license": "PolyForm-Shield-1.0.0",
6
6
  "type": "module",
@@ -7,7 +7,7 @@ export const flowHistorySummary=(entry,placementId)=>({...entry,...projectFlowRe
7
7
  const safePreview=value=>value?redactProjectFlowMediaAddresses(value):null;
8
8
  export const flowInputSummary=input=>({key:input.key,label:input.label,state:input.state,materialized:input.materialized,source:input.source,sourcePlacementId:input.sourcePlacementId,sourceObservation:input.sourceObservation||null,explicit:input.explicit,reason:input.reason,selectionReason:input.selectionReason,valuePolicy:input.valuePolicy,pinned:input.pinned===true,pinRunId:input.pinRunId||null,canPinVersion:input.canPinVersion===true,pinChoices:input.pinChoices?.map(choice=>({runId:choice.runId,resultId:choice.pinSource.resultId||null,label:choice.label,preview:safePreview(choice.preview)}))||[],preview:safePreview(input.preview),candidates:input.candidates.slice(0,3),candidateCount:input.candidateCount,candidateRevision:input.candidateRevision,candidatesOmitted:(input.candidatesOmitted||0)+Math.max(0,input.candidates.length-3)});
9
9
  export const flowOutputSummary=output=>({key:output.key,state:output.state,observation:output.observation||null,availabilityReason:output.availabilityReason||null,preview:safePreview(output.preview)});
10
- export const flowPlacementSummary=item=>({id:item.id,name:item.label,state:item.state,condition:item.condition||null,retainedLoopRuns:safeWorkflowLoopSummaries(item.retainedLoopRuns),localMediaInputs:item.localMediaInputs||[],inputs:item.inputs.slice(0,8).map(flowInputSummary),inputsTotal:item.inputs.length,inputsOffset:0,inputsLimit:8,inputsNextOffset:item.inputs.length>8?8:null,outputs:item.outputs.slice(0,8).map(flowOutputSummary),outputsTotal:item.outputs.length,outputsOffset:0,outputsLimit:8,outputsNextOffset:item.outputs.length>8?8:null,unusedResults:item.unusedResults?.slice(0,3).map(candidate=>({candidateId:candidate.candidateId,kind:candidate.kind,...(candidate.kind==='loop'?{loopRunId:candidate.loopRunId,loopReceiptDigest:candidate.loopReceiptDigest,loopStartPlacementId:candidate.loopStartPlacementId,iteration:candidate.iteration,historical:true,verification:candidate.verification}:{}),...(candidate.selection?{selection:candidate.selection}:{}),runId:candidate.runId,at:candidate.at,reason:candidate.reason,outputDigest:candidate.outputDigest,inputsCount:candidate.inputsCount,outputsCount:candidate.outputsCount,applied:false}))||[],unusedResultsRevision:item.unusedResultsRevision,unusedResultsCount:item.unusedResultsCount,unusedResultsNextOffset:(item.unusedResultsCount||0)>3?3:null,usedInputs:item.usedInputs?.slice(0,8)||null,history:(item.history||[]).slice(0,3).map(entry=>flowHistorySummary(entry,item.id)),historyTotal:(item.history||[]).length,historyOffset:0,historyLimit:3,historyNextOffset:(item.history||[]).length>3?3:null,historyComplete:item.historyComplete===true&&(item.history||[]).length<=3,retainedHistoryComplete:item.historyComplete===true,activity:item.activity});
10
+ export const flowPlacementSummary=item=>({id:item.id,name:item.label,...(item.step?{step:item.step}:{}),state:item.state,condition:item.condition||null,retainedLoopRuns:safeWorkflowLoopSummaries(item.retainedLoopRuns),localMediaInputs:item.localMediaInputs||[],inputs:item.inputs.slice(0,8).map(flowInputSummary),inputsTotal:item.inputs.length,inputsOffset:0,inputsLimit:8,inputsNextOffset:item.inputs.length>8?8:null,outputs:item.outputs.slice(0,8).map(flowOutputSummary),outputsTotal:item.outputs.length,outputsOffset:0,outputsLimit:8,outputsNextOffset:item.outputs.length>8?8:null,unusedResults:item.unusedResults?.slice(0,3).map(candidate=>({candidateId:candidate.candidateId,kind:candidate.kind,...(candidate.kind==='loop'?{loopRunId:candidate.loopRunId,loopReceiptDigest:candidate.loopReceiptDigest,loopStartPlacementId:candidate.loopStartPlacementId,iteration:candidate.iteration,historical:true,verification:candidate.verification}:{}),...(candidate.selection?{selection:candidate.selection}:{}),runId:candidate.runId,at:candidate.at,reason:candidate.reason,outputDigest:candidate.outputDigest,inputsCount:candidate.inputsCount,outputsCount:candidate.outputsCount,applied:false}))||[],unusedResultsRevision:item.unusedResultsRevision,unusedResultsCount:item.unusedResultsCount,unusedResultsNextOffset:(item.unusedResultsCount||0)>3?3:null,usedInputs:item.usedInputs?.slice(0,8)||null,history:(item.history||[]).slice(0,3).map(entry=>flowHistorySummary(entry,item.id)),historyTotal:(item.history||[]).length,historyOffset:0,historyLimit:3,historyNextOffset:(item.history||[]).length>3?3:null,historyComplete:item.historyComplete===true&&(item.history||[]).length<=3,retainedHistoryComplete:item.historyComplete===true,activity:item.activity});
11
11
 
12
12
  export function readFlowAgentPage(flow,{operation,placementId,kind,offset,limit,revision},{projectId,currentRevision,focusedId}={}){
13
13
  if(revision!==currentRevision)throw new Error('The project Flow changed. Inspect Flow again before paging.');
@@ -148,6 +148,9 @@ export function inspectProjectFlow(type,canvas={nodes:[],edges:[]},{projectId=nu
148
148
  const archivedByStage=new Map();
149
149
  for(const entry of canvas.workflowArchive||[])if(entry.step?.id&&entry.node?.data?.blockLastRun){if(!archivedByStage.has(entry.step.id))archivedByStage.set(entry.step.id,[]);archivedByStage.get(entry.step.id).push(entry);}
150
150
  const manifestFor=(stage,node)=>node?.data?.blockPackage?.manifest||(packageForStage(type,stage,[...BUILTIN_BLOCKS,...catalog])?.manifest);
151
+ // The Step (job) a Block sits in, by the name the person sees (October 19, 2026: Chat read
152
+ // Block names only and told a tester her "Upload sermon" Step did not exist).
153
+ const stepOf=new Map((type.groups||[]).filter(group=>typeof group?.label==='string'&&group.label&&Array.isArray(group.stages)).flatMap(group=>group.stages.map(id=>[id,group.label])));
151
154
  const placements=audit.stages.map(row=>{
152
155
  const stage=type.stages.find(item=>item.id===row.id),node=nodes.get(stageNodeId(stage)),manifest=manifestFor(stage,node);
153
156
  const outputs=Object.entries(manifest?.outputs||{}).map(([key,port])=>{
@@ -254,7 +257,7 @@ export function inspectProjectFlow(type,canvas={nodes:[],edges:[]},{projectId=nu
254
257
  const appliedActivity=last?.manualSelection?{applied:true,at:last.at||null,runId:null,...(selectedManually?{selection:selectedManually}:{})}:last?{applied:true,at:last.at||null,runId:last.executionRunId||last.semanticRunId||last.segmentRunId||last.runId||null,skipped:last.skipped===true&&!last.failureRecovery,execution:executionEvidence(last,{conditional:!!stage.condition,outputs:node?.data?.blockOutputs||{}})}:null;
255
258
  const condition=stage.condition?{inputKey:stage.condition.input,inputLabel:manifest?.inputs?.[stage.condition.input]?.description||stage.condition.input,path:stage.condition.path.slice(0,8),operator:stage.condition.operator,outcome:last?(last.skipped===true&&!last.failureRecovery?'skipped':'ran'):'unknown'}:null;
256
259
  const latestFailure=(node?.data?.blockFailedAttempts||[]).slice(-1)[0],failureIsLatest=typeof latestFailure?.runId==='string'&&Number.isFinite(Date.parse(latestFailure.at))&&(!last?.at||latestFailure.at>last.at)&&!failureRecovered(latestFailure),failedActivity=failureIsLatest?{runId:latestFailure.runId.slice(0,160),at:latestFailure.at,code:failureCode(latestFailure),durationMs:Number.isSafeInteger(latestFailure.diagnostic?.durationMs)?Math.max(0,Math.min(86400000,latestFailure.diagnostic.durationMs)):null,...Object.fromEntries(['attempt','batchIndex','batchCount'].filter(key=>failedCounter(latestFailure[key])).map(key=>[key,latestFailure[key]]))}:null;
257
- return {id:row.id,nodeId:stageNodeId(stage),label:row.label,blockId:stage.blockId,version:stage.version,...(manifest?.permissions?.capabilities?.includes('connection.execute')?{connector:true}:{}),transformation:String(manifest?.description||'').slice(0,500),enabled:stage.enabled!==false,state:node?.data?.blockPrivacy?'restricted':active?'working':row.paused?'paused':node?.data?.blockStale||last&&last.digest!==node?.data?.blockDigest?'needs-updating':failureIsLatest?(failureCode(latestFailure)==='EXECUTION_FAILED'?'failed':'needs-recovery'):last?.skipped===true&&!last?.failureRecovery&&stage.condition?'skipped':last?'finished':row.state==='broken'||row.state==='needs-user-input'?'needs-choice':'waiting',...(condition?{condition}:{}),inputs:inputs.map(input=>({...input,valueType:manifest?.inputs?.[input.key]?.type||row.requirements.find(requirement=>requirement.key===input.key)?.requirement?.type||null})),outputs,unusedResults,unusedResultsRevision,unusedResultsCount,unusedResultsOffset,unusedResultsLimit,unusedResultsNextOffset,...(retainedPage?.candidateId?{inspectedRetainedResult}:{}),usedInputs,activity:active?{...(appliedActivity||{}),active,activeRuns,activeRunsOmitted:Math.max(0,(runsByPlacement.get(row.id)||[]).length-activeRuns.length)}:failedActivity?{...(appliedActivity||{}),failedAttempt:failedActivity}:appliedActivity,history:recentHistory,historyComplete,retainedLoopRuns:retainedWorkflowLoopSummaries(canvas,row.id)};
260
+ return {id:row.id,nodeId:stageNodeId(stage),label:row.label,...(stepOf.has(row.id)?{step:stepOf.get(row.id)}:{}),blockId:stage.blockId,version:stage.version,...(manifest?.permissions?.capabilities?.includes('connection.execute')?{connector:true}:{}),transformation:String(manifest?.description||'').slice(0,500),enabled:stage.enabled!==false,state:node?.data?.blockPrivacy?'restricted':active?'working':row.paused?'paused':node?.data?.blockStale||last&&last.digest!==node?.data?.blockDigest?'needs-updating':failureIsLatest?(failureCode(latestFailure)==='EXECUTION_FAILED'?'failed':'needs-recovery'):last?.skipped===true&&!last?.failureRecovery&&stage.condition?'skipped':last?'finished':row.state==='broken'||row.state==='needs-user-input'?'needs-choice':'waiting',...(condition?{condition}:{}),inputs:inputs.map(input=>({...input,valueType:manifest?.inputs?.[input.key]?.type||row.requirements.find(requirement=>requirement.key===input.key)?.requirement?.type||null})),outputs,unusedResults,unusedResultsRevision,unusedResultsCount,unusedResultsOffset,unusedResultsLimit,unusedResultsNextOffset,...(retainedPage?.candidateId?{inspectedRetainedResult}:{}),usedInputs,activity:active?{...(appliedActivity||{}),active,activeRuns,activeRunsOmitted:Math.max(0,(runsByPlacement.get(row.id)||[]).length-activeRuns.length)}:failedActivity?{...(appliedActivity||{}),failedAttempt:failedActivity}:appliedActivity,history:recentHistory,historyComplete,retainedLoopRuns:retainedWorkflowLoopSummaries(canvas,row.id)};
258
261
  });
259
262
  if(retainedResultPage){const placement=placements.find(item=>item.id===retainedResultPage.placementId);if(!placement||placement.unusedResultsRevision!==retainedResultPage.revision||retainedResultPage.operation==='list'&&retainedResultPage.offset>placement.unusedResultsCount||retainedResultPage.candidateId&&!placement.inspectedRetainedResult)throw new Error('This retained result list or exact candidate changed. Inspect Result again.');}
260
263
  if(receiptInputPage){const entry=placements.find(item=>item.id===receiptInputPage.placementId)?.history?.find(item=>projectFlowRunReceiptIdentity(item)===receiptInputPage.receiptId);if(!entry||entry.inputsRevision!==receiptInputPage.revision||!entry.inputs||receiptInputPage.offset>entry.inputsCount)throw new Error('This retained receipt or its captured inputs changed. Inspect the run again.');}