stillmade-sdk 0.1.37 → 0.1.38
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.
- package/PASTE_TO_LLM.md +1 -1
- package/SDK.md +8 -8
- package/blocks/narration/package.js +1 -1
- package/blocks/narration/versions/1.0.1/package.js +221 -0
- package/blocks/native/sm.animated-scene/versions/2.3.9/package.js +769 -0
- package/blocks/native/sm.board-editor/versions/2.2.11/package.js +551 -0
- package/blocks/native/sm.panel-strip/versions/2.4.2/package.js +1316 -0
- package/blocks/native/sm.pipeline/versions/2.2.6/package.js +1095 -0
- package/blocks/screen-recorder/package.js +1 -1
- package/blocks/screen-recorder/versions/1.0.1/package.js +223 -0
- package/package.json +1 -1
- package/packages/block-platform/catalog.js +1 -1
- package/packages/block-sdk/first-party-identities.js +6 -0
- package/packages/block-sdk/first-party-packages.js +7 -1
- package/packages/block-sdk/view-design.js +55 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
export {default} from './versions/1.0.
|
|
1
|
+
export {default} from './versions/1.0.1/package.js';
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Generated from the owned versioned source by build-package.mjs.
|
|
2
|
+
export default {
|
|
3
|
+
"manifest": {
|
|
4
|
+
"schemaVersion": 1,
|
|
5
|
+
"sdkVersion": "0.1.0",
|
|
6
|
+
"id": "stillmade.screen-recorder",
|
|
7
|
+
"version": "1.0.1",
|
|
8
|
+
"name": "Screen Recorder",
|
|
9
|
+
"description": "Record a screen or window in your browser or in StillMade Desktop, keep it on your device, cut out what you do not want, and pass the result on.",
|
|
10
|
+
"kind": "task",
|
|
11
|
+
"runtime": "javascript",
|
|
12
|
+
"entry": "src/run.js",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"inputs": {
|
|
15
|
+
"video": {
|
|
16
|
+
"type": "video",
|
|
17
|
+
"primary": true,
|
|
18
|
+
"required": false,
|
|
19
|
+
"semantic": "source_video",
|
|
20
|
+
"sources": [
|
|
21
|
+
"upstream",
|
|
22
|
+
"user"
|
|
23
|
+
],
|
|
24
|
+
"description": "A video to cut. Leave it empty to record one."
|
|
25
|
+
},
|
|
26
|
+
"original": {
|
|
27
|
+
"type": "video",
|
|
28
|
+
"required": false,
|
|
29
|
+
"semantic": "source_video",
|
|
30
|
+
"sources": [
|
|
31
|
+
"user"
|
|
32
|
+
],
|
|
33
|
+
"description": "The uncut recording, kept beside the result."
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"outputs": {
|
|
37
|
+
"video": {
|
|
38
|
+
"type": "video",
|
|
39
|
+
"primary": true,
|
|
40
|
+
"semantic": "edited_video",
|
|
41
|
+
"description": "The recording with the removed parts cut out, kept on this device until it is passed on."
|
|
42
|
+
},
|
|
43
|
+
"original": {
|
|
44
|
+
"type": "video",
|
|
45
|
+
"semantic": "source_video",
|
|
46
|
+
"description": "The uncut recording."
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"permissions": {
|
|
50
|
+
"project": [],
|
|
51
|
+
"network": [],
|
|
52
|
+
"filesystem": [],
|
|
53
|
+
"secrets": [],
|
|
54
|
+
"desktop": [
|
|
55
|
+
"screen.capture",
|
|
56
|
+
"microphone.capture"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"ui": [],
|
|
60
|
+
"platforms": {
|
|
61
|
+
"phone": {
|
|
62
|
+
"supported": false,
|
|
63
|
+
"reason": "Recording and cutting need a desktop browser or StillMade Desktop."
|
|
64
|
+
},
|
|
65
|
+
"browser": {
|
|
66
|
+
"supported": true
|
|
67
|
+
},
|
|
68
|
+
"desktop": {
|
|
69
|
+
"supported": true
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"provenance": {
|
|
73
|
+
"notice": "Original source (authorship declared by the packager)\nLicense: MIT\nChanges: Original implementation declared by the packager; no reused repository or dependency code.\nFiles: LICENSE, src/run.js, src/view.json\n\nMIT 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",
|
|
74
|
+
"portableSources": [
|
|
75
|
+
{
|
|
76
|
+
"kind": "original",
|
|
77
|
+
"license": "MIT",
|
|
78
|
+
"paths": [
|
|
79
|
+
"LICENSE",
|
|
80
|
+
"src/run.js",
|
|
81
|
+
"src/view.json"
|
|
82
|
+
],
|
|
83
|
+
"evidence": [
|
|
84
|
+
"upstream/0/LICENSE",
|
|
85
|
+
"upstream/0/src/run.js",
|
|
86
|
+
"upstream/0/src/view.json"
|
|
87
|
+
],
|
|
88
|
+
"changes": "Original implementation declared by the packager; no reused repository or dependency code."
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
},
|
|
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
|
+
"view": {
|
|
95
|
+
"runtime": "frame",
|
|
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\" data-stillmade-action=\"play-pause\" 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\" data-stillmade-action=\"cut-here\">Cut here</button>\n <button type=\"button\" class=\"sm-quiet\" id=\"toggle\" data-stillmade-action=\"remove-or-keep-part\" disabled>Remove part</button>\n <button type=\"button\" class=\"sm-quiet\" id=\"undo\" data-stillmade-action=\"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\" data-stillmade-action=\"record-screen\">Record screen</button>\n <button type=\"button\" class=\"sm-quiet\" id=\"secondary\" data-stillmade-action=\"record-again\" 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};\n// 1.0.1: what the two action buttons do in each phase, for Chat (data-stillmade-action).\nconst NAMES = { ready: ['record-screen', 'record-again'], live: ['stop-and-edit', 'discard-recording'], editor: ['save-recording', 'record-again'], done: ['edit-again', 'record-again'] };\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.stillmadeAction = NAMES[phase][0]; $('secondary').dataset.stillmadeAction = NAMES[phase][1];\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); node.dataset.stillmadeAction = 'select-part';\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
|
+
},
|
|
100
|
+
"tests": [
|
|
101
|
+
{
|
|
102
|
+
"name": "Passes the cut video and the uncut original on",
|
|
103
|
+
"input": {
|
|
104
|
+
"video": {
|
|
105
|
+
"kind": "video",
|
|
106
|
+
"assetId": "cut-asset",
|
|
107
|
+
"versionId": "cut-v1",
|
|
108
|
+
"url": "https://media.example/recording-cut.mp4",
|
|
109
|
+
"mimeType": "video/mp4",
|
|
110
|
+
"width": 1920,
|
|
111
|
+
"height": 1080,
|
|
112
|
+
"duration": 61.2,
|
|
113
|
+
"bytes": 9000000
|
|
114
|
+
},
|
|
115
|
+
"original": {
|
|
116
|
+
"kind": "video",
|
|
117
|
+
"assetId": "recording-asset",
|
|
118
|
+
"versionId": "recording-v1",
|
|
119
|
+
"url": "https://media.example/recording.mp4",
|
|
120
|
+
"mimeType": "video/mp4",
|
|
121
|
+
"width": 1920,
|
|
122
|
+
"height": 1080,
|
|
123
|
+
"duration": 92.4,
|
|
124
|
+
"bytes": 9000000
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"expected": {
|
|
128
|
+
"video": {
|
|
129
|
+
"kind": "video",
|
|
130
|
+
"assetId": "cut-asset",
|
|
131
|
+
"versionId": "cut-v1",
|
|
132
|
+
"url": "https://media.example/recording-cut.mp4",
|
|
133
|
+
"mimeType": "video/mp4",
|
|
134
|
+
"width": 1920,
|
|
135
|
+
"height": 1080,
|
|
136
|
+
"duration": 61.2,
|
|
137
|
+
"bytes": 9000000
|
|
138
|
+
},
|
|
139
|
+
"original": {
|
|
140
|
+
"kind": "video",
|
|
141
|
+
"assetId": "recording-asset",
|
|
142
|
+
"versionId": "recording-v1",
|
|
143
|
+
"url": "https://media.example/recording.mp4",
|
|
144
|
+
"mimeType": "video/mp4",
|
|
145
|
+
"width": 1920,
|
|
146
|
+
"height": 1080,
|
|
147
|
+
"duration": 92.4,
|
|
148
|
+
"bytes": 9000000
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"name": "Uses the video itself as the original when nothing was cut",
|
|
154
|
+
"input": {
|
|
155
|
+
"video": {
|
|
156
|
+
"kind": "video",
|
|
157
|
+
"assetId": "recording-asset",
|
|
158
|
+
"versionId": "recording-v1",
|
|
159
|
+
"url": "https://media.example/recording.mp4",
|
|
160
|
+
"mimeType": "video/mp4",
|
|
161
|
+
"width": 1920,
|
|
162
|
+
"height": 1080,
|
|
163
|
+
"duration": 92.4,
|
|
164
|
+
"bytes": 9000000
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
"expected": {
|
|
168
|
+
"video": {
|
|
169
|
+
"kind": "video",
|
|
170
|
+
"assetId": "recording-asset",
|
|
171
|
+
"versionId": "recording-v1",
|
|
172
|
+
"url": "https://media.example/recording.mp4",
|
|
173
|
+
"mimeType": "video/mp4",
|
|
174
|
+
"width": 1920,
|
|
175
|
+
"height": 1080,
|
|
176
|
+
"duration": 92.4,
|
|
177
|
+
"bytes": 9000000
|
|
178
|
+
},
|
|
179
|
+
"original": {
|
|
180
|
+
"kind": "video",
|
|
181
|
+
"assetId": "recording-asset",
|
|
182
|
+
"versionId": "recording-v1",
|
|
183
|
+
"url": "https://media.example/recording.mp4",
|
|
184
|
+
"mimeType": "video/mp4",
|
|
185
|
+
"width": 1920,
|
|
186
|
+
"height": 1080,
|
|
187
|
+
"duration": 92.4,
|
|
188
|
+
"bytes": 9000000
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
],
|
|
193
|
+
"portable": {
|
|
194
|
+
"format": "stillmade-block/1",
|
|
195
|
+
"dependencies": [],
|
|
196
|
+
"sources": [
|
|
197
|
+
{
|
|
198
|
+
"kind": "original",
|
|
199
|
+
"license": "MIT",
|
|
200
|
+
"paths": [
|
|
201
|
+
"LICENSE",
|
|
202
|
+
"src/run.js",
|
|
203
|
+
"src/view.json"
|
|
204
|
+
],
|
|
205
|
+
"evidence": [
|
|
206
|
+
"upstream/0/LICENSE",
|
|
207
|
+
"upstream/0/src/run.js",
|
|
208
|
+
"upstream/0/src/view.json"
|
|
209
|
+
],
|
|
210
|
+
"changes": "Original implementation declared by the packager; no reused repository or dependency code."
|
|
211
|
+
}
|
|
212
|
+
],
|
|
213
|
+
"files": {
|
|
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
|
+
"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\\\">\\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\\\" data-stillmade-action=\\\"play-pause\\\" 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\\\" data-stillmade-action=\\\"cut-here\\\">Cut here</button>\\n <button type=\\\"button\\\" class=\\\"sm-quiet\\\" id=\\\"toggle\\\" data-stillmade-action=\\\"remove-or-keep-part\\\" disabled>Remove part</button>\\n <button type=\\\"button\\\" class=\\\"sm-quiet\\\" id=\\\"undo\\\" data-stillmade-action=\\\"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\\\" data-stillmade-action=\\\"record-screen\\\">Record screen</button>\\n <button type=\\\"button\\\" class=\\\"sm-quiet\\\" id=\\\"secondary\\\" data-stillmade-action=\\\"record-again\\\" 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};\\n// 1.0.1: what the two action buttons do in each phase, for Chat (data-stillmade-action).\\nconst NAMES = { ready: ['record-screen', 'record-again'], live: ['stop-and-edit', 'discard-recording'], editor: ['save-recording', 'record-again'], done: ['edit-again', 'record-again'] };\\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.stillmadeAction = NAMES[phase][0]; $('secondary').dataset.stillmadeAction = NAMES[phase][1];\\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); node.dataset.stillmadeAction = 'select-part';\\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
|
+
},
|
|
218
|
+
"lineage": {
|
|
219
|
+
"parents": [],
|
|
220
|
+
"changes": "Packaged original Block source for StillMade."
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
package/package.json
CHANGED
|
@@ -88,7 +88,7 @@ HOST_WORKSPACE_BLOCKS.splice(HOST_WORKSPACE_BLOCKS.findIndex(block=>block.manife
|
|
|
88
88
|
// Promote a portable workspace after its accepted production MVP and rendered
|
|
89
89
|
// acceptance are complete; advanced host extensions can remain on old exact pins. Exact portable releases remain usable as normal
|
|
90
90
|
// Steps before promotion; this map only controls the default production route.
|
|
91
|
-
export const CURRENT_PORTABLE_WORKSPACE_VERSIONS=Object.freeze({'sm.chat':'2.0.3','sm.script':'2.1.5','sm.voiceover':'2.1.6','sm.shot-plan':'2.2.1','sm.blueprint':'2.1.9','sm.pipeline':'2.2.
|
|
91
|
+
export const CURRENT_PORTABLE_WORKSPACE_VERSIONS=Object.freeze({'sm.chat':'2.0.3','sm.script':'2.1.5','sm.voiceover':'2.1.6','sm.shot-plan':'2.2.1','sm.blueprint':'2.1.9','sm.pipeline':'2.2.6','sm.video-assets':'2.1.4','sm.panel-strip':'2.4.2','sm.board-editor':'2.2.11','sm.animated-scene':'2.3.9','sm.editor':'2.2.11'});
|
|
92
92
|
// Workspaces whose main release stays the host one: the original Editor (1.0) is
|
|
93
93
|
// the full video editor. Its accepted portable release stays discoverable for
|
|
94
94
|
// Steps that hand the Editor a prepared timeline, which only it accepts.
|
|
@@ -17,6 +17,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
|
|
|
17
17
|
"sm.animated-scene@2.3.6",
|
|
18
18
|
"sm.animated-scene@2.3.7",
|
|
19
19
|
"sm.animated-scene@2.3.8",
|
|
20
|
+
"sm.animated-scene@2.3.9",
|
|
20
21
|
"sm.blueprint-assistant@1.0.0",
|
|
21
22
|
"sm.blueprint@2.0.0",
|
|
22
23
|
"sm.blueprint@2.1.0",
|
|
@@ -34,6 +35,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
|
|
|
34
35
|
"sm.board-editor@2.2.0",
|
|
35
36
|
"sm.board-editor@2.2.1",
|
|
36
37
|
"sm.board-editor@2.2.10",
|
|
38
|
+
"sm.board-editor@2.2.11",
|
|
37
39
|
"sm.board-editor@2.2.2",
|
|
38
40
|
"sm.board-editor@2.2.3",
|
|
39
41
|
"sm.board-editor@2.2.4",
|
|
@@ -113,6 +115,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
|
|
|
113
115
|
"sm.panel-strip@2.3.7",
|
|
114
116
|
"sm.panel-strip@2.4.0",
|
|
115
117
|
"sm.panel-strip@2.4.1",
|
|
118
|
+
"sm.panel-strip@2.4.2",
|
|
116
119
|
"sm.photo-notes@1.0.0",
|
|
117
120
|
"sm.photo-notes@1.1.0",
|
|
118
121
|
"sm.pick-photos@1.0.0",
|
|
@@ -124,6 +127,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
|
|
|
124
127
|
"sm.pipeline@2.2.3",
|
|
125
128
|
"sm.pipeline@2.2.4",
|
|
126
129
|
"sm.pipeline@2.2.5",
|
|
130
|
+
"sm.pipeline@2.2.6",
|
|
127
131
|
"sm.quality-audit@1.0.0",
|
|
128
132
|
"sm.quality-audit@1.0.1",
|
|
129
133
|
"sm.quality-audit@1.0.2",
|
|
@@ -179,6 +183,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
|
|
|
179
183
|
"sm.write-summary@1.0.0",
|
|
180
184
|
"sm.youtube@2.0.0",
|
|
181
185
|
"stillmade.narration@1.0.0",
|
|
186
|
+
"stillmade.narration@1.0.1",
|
|
182
187
|
"stillmade.occasion-story@1.0.0",
|
|
183
188
|
"stillmade.occasion-story@1.0.1",
|
|
184
189
|
"stillmade.occasion-story@1.0.2",
|
|
@@ -190,6 +195,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
|
|
|
190
195
|
"stillmade.release-visuals@1.0.1",
|
|
191
196
|
"stillmade.release-visuals@1.0.2",
|
|
192
197
|
"stillmade.screen-recorder@1.0.0",
|
|
198
|
+
"stillmade.screen-recorder@1.0.1",
|
|
193
199
|
"stillmade.short-form-finish@1.0.0",
|
|
194
200
|
"stillmade.short-form-finish@1.0.1",
|
|
195
201
|
"stillmade.short-form-finish@1.0.2",
|
|
@@ -2,7 +2,9 @@ import shortFormFinishV100 from '../../blocks/short-form-finish/versions/1.0.0/p
|
|
|
2
2
|
import shortFormFinishV101 from '../../blocks/short-form-finish/versions/1.0.1/package.js';
|
|
3
3
|
import shortFormFinishV102 from '../../blocks/short-form-finish/versions/1.0.2/package.js';
|
|
4
4
|
import shortFormFinish from '../../blocks/short-form-finish/package.js';
|
|
5
|
+
import screenRecorderV100 from '../../blocks/screen-recorder/versions/1.0.0/package.js';
|
|
5
6
|
import screenRecorder from '../../blocks/screen-recorder/package.js';
|
|
7
|
+
import narrationV100 from '../../blocks/narration/versions/1.0.0/package.js';
|
|
6
8
|
import narration from '../../blocks/narration/package.js';
|
|
7
9
|
import occasionStoryV100 from '../../blocks/occasion-story/versions/1.0.0/package.js';
|
|
8
10
|
import releaseVisualsV100 from '../../blocks/release-visuals/versions/1.0.0/package.js';
|
|
@@ -138,6 +140,7 @@ import pipelineV220 from '../../blocks/native/sm.pipeline/versions/2.2.0/package
|
|
|
138
140
|
import pipelineV221 from '../../blocks/native/sm.pipeline/versions/2.2.1/package.js';
|
|
139
141
|
import pipelineV223 from '../../blocks/native/sm.pipeline/versions/2.2.3/package.js';
|
|
140
142
|
import pipelineV225 from '../../blocks/native/sm.pipeline/versions/2.2.5/package.js';
|
|
143
|
+
import pipelineV226 from '../../blocks/native/sm.pipeline/versions/2.2.6/package.js';
|
|
141
144
|
import pipelineV224 from '../../blocks/native/sm.pipeline/versions/2.2.4/package.js';
|
|
142
145
|
import pipelineV222 from '../../blocks/native/sm.pipeline/versions/2.2.2/package.js';
|
|
143
146
|
import shotPlanAssistant from '../../blocks/native/sm.shot-plan-assistant/versions/1.0.0/package.js';
|
|
@@ -166,6 +169,7 @@ import panelStripV236 from '../../blocks/native/sm.panel-strip/versions/2.3.6/pa
|
|
|
166
169
|
import panelStripV237 from '../../blocks/native/sm.panel-strip/versions/2.3.7/package.js';
|
|
167
170
|
import panelStripV240 from '../../blocks/native/sm.panel-strip/versions/2.4.0/package.js';
|
|
168
171
|
import panelStripV241 from '../../blocks/native/sm.panel-strip/versions/2.4.1/package.js';
|
|
172
|
+
import panelStripV242 from '../../blocks/native/sm.panel-strip/versions/2.4.2/package.js';
|
|
169
173
|
import boardEditorV210 from '../../blocks/native/sm.board-editor/versions/2.1.0/package.js';
|
|
170
174
|
import boardEditorV220 from '../../blocks/native/sm.board-editor/versions/2.2.0/package.js';
|
|
171
175
|
import boardEditorV221 from '../../blocks/native/sm.board-editor/versions/2.2.1/package.js';
|
|
@@ -178,6 +182,7 @@ import boardEditorV227 from '../../blocks/native/sm.board-editor/versions/2.2.7/
|
|
|
178
182
|
import boardEditorV228 from '../../blocks/native/sm.board-editor/versions/2.2.8/package.js';
|
|
179
183
|
import boardEditorV229 from '../../blocks/native/sm.board-editor/versions/2.2.9/package.js';
|
|
180
184
|
import boardEditorV2210 from '../../blocks/native/sm.board-editor/versions/2.2.10/package.js';
|
|
185
|
+
import boardEditorV2211 from '../../blocks/native/sm.board-editor/versions/2.2.11/package.js';
|
|
181
186
|
import animatedSceneV220 from '../../blocks/native/sm.animated-scene/versions/2.2.0/package.js';
|
|
182
187
|
import animatedSceneV230 from '../../blocks/native/sm.animated-scene/versions/2.3.0/package.js';
|
|
183
188
|
import animatedSceneV231 from '../../blocks/native/sm.animated-scene/versions/2.3.1/package.js';
|
|
@@ -188,6 +193,7 @@ import animatedSceneV235 from '../../blocks/native/sm.animated-scene/versions/2.
|
|
|
188
193
|
import animatedSceneV236 from '../../blocks/native/sm.animated-scene/versions/2.3.6/package.js';
|
|
189
194
|
import animatedSceneV237 from '../../blocks/native/sm.animated-scene/versions/2.3.7/package.js';
|
|
190
195
|
import animatedSceneV238 from '../../blocks/native/sm.animated-scene/versions/2.3.8/package.js';
|
|
196
|
+
import animatedSceneV239 from '../../blocks/native/sm.animated-scene/versions/2.3.9/package.js';
|
|
191
197
|
import blueprintAssistant from '../../blocks/native/sm.blueprint-assistant/versions/1.0.0/package.js';
|
|
192
198
|
import photoNotes from '../../blocks/native/sm.photo-notes/versions/1.0.0/package.js';
|
|
193
199
|
import photoNotesV110 from '../../blocks/native/sm.photo-notes/versions/1.1.0/package.js';
|
|
@@ -196,5 +202,5 @@ import pickPhotos from '../../blocks/native/sm.pick-photos/versions/1.0.0/packag
|
|
|
196
202
|
// Exact, independently executable first-party releases. Host workspaces do not
|
|
197
203
|
// belong here. Changing implementation requires a new immutable version.
|
|
198
204
|
const freeze=value=>{if(value&&typeof value==='object'&&!Object.isFrozen(value)){Object.values(value).forEach(freeze);Object.freeze(value);}return value;};
|
|
199
|
-
export const FIRST_PARTY_SDK_PACKAGES = Object.freeze([freeze(productAdVariants),freeze(pickPhotos),freeze(photoNotesV110),freeze(photoNotes),freeze(youtubePublication),freeze(stockSearch),freeze(exportVideoV210),freeze(exportVideo),freeze(exportAudio),freeze(motionGraphics),freeze(audioScore),freeze(shortFormFinish),freeze(screenRecorder),freeze(narration),freeze(shortFormFinishV102),freeze(shortFormFinishV101),freeze(shortFormFinishV100),freeze(occasionStory),freeze(occasionStoryV101),freeze(occasionStoryV100),freeze(releaseVisuals),freeze(releaseVisualsV101),freeze(releaseVisualsV100),freeze(photoReelComposer),freeze(photoReelComposerV101),freeze(photoReelComposerV100),freeze(importMediaV202),freeze(importMediaV201),freeze(editorV2212),freeze(editorV2211),freeze(editorV2210),freeze(editorV229),freeze(editorV228),freeze(panelStripV241),freeze(panelStripV240),freeze(panelStripV237),freeze(panelStripV236),freeze(boardEditorV2210),freeze(boardEditorV229),freeze(animatedSceneV238),freeze(animatedSceneV237),freeze(pipelineV225),freeze(pipelineV224),freeze(blueprintV219),freeze(blueprintV218),freeze(videoAssetsV214),freeze(videoAssetsV213),freeze(chatV203),freeze(chatV202),freeze(chatV201),freeze(shotPlanV221),freeze(shotPlanV220),freeze(shotPlanV213),freeze(shotPlanV212),freeze(voiceoverV216),freeze(voiceoverV215),freeze(voiceoverV214),freeze(voiceoverV213),freeze(scriptV215),freeze(scriptV214),freeze(scriptV213),freeze(roughCutV202),freeze(roughCutV201),freeze(finalEditV202),freeze(finalEditV201),freeze(editAssistantV202),freeze(editAssistantV201),freeze(gradeV202),freeze(gradeV201),freeze(transitionV202),freeze(transitionV201),freeze(qualityAuditV102),freeze(qualityAuditV101),freeze(compareV202),freeze(compareV201),freeze(compare),freeze(grade),freeze(transition),freeze(exportTimeline),freeze(captions),freeze(importMedia),freeze(generateImageV210),freeze(generateImage),freeze(thumbnailV201),freeze(thumbnail),freeze(writeSummary),freeze(automationV110),freeze(automationWhen),freeze(automationNewItems),freeze(automationAiDecide),freeze(automationContinueIf),freeze(connectApp),freeze(automationContactFields),freeze(automationAiWrite),freeze(automationMetaCreative),freeze(voiceover),freeze(voiceoverV210),freeze(voiceoverV211),freeze(voiceoverV212),freeze(script),freeze(scriptV210),freeze(scriptV211),freeze(scriptV212),freeze(generateVideoV201),freeze(generateVideo),freeze(chat),freeze(characterV201),freeze(character),freeze(locationV201),freeze(location),freeze(styleV201),freeze(style),freeze(researchV202),freeze(researchV201),freeze(research),freeze(imageEditorV201),freeze(imageEditor),freeze(describeMediaV210),freeze(describeMediaV201),freeze(describeMedia),freeze(videoAssets),freeze(videoAssetsV210),freeze(videoAssetsV211),freeze(videoAssetsV212),freeze(roughCut),freeze(finalEdit),freeze(editAssistant),freeze(transcribeV201),freeze(transcribe),freeze(blueprint),freeze(blueprintV210),freeze(blueprintV211),freeze(blueprintV212),freeze(blueprintV213),freeze(blueprintV214),freeze(blueprintV215),freeze(blueprintV216),freeze(blueprintV217),freeze(shotPlan),freeze(shotPlanV210),freeze(shotPlanV211),freeze(boardEditor),freeze(boardEditorV210),freeze(boardEditorV220),freeze(boardEditorV221),freeze(boardEditorV222),freeze(boardEditorV223),freeze(boardEditorV224),freeze(boardEditorV225),freeze(boardEditorV226),freeze(boardEditorV227),freeze(boardEditorV228),freeze(panelStripV200),freeze(panelStrip),freeze(panelStripV220),freeze(panelStripV230),freeze(panelStripV231),freeze(panelStripV232),freeze(panelStripV233),freeze(panelStripV234),freeze(panelStripV235),freeze(animatedSceneV200),freeze(animatedScene),freeze(animatedSceneV220),freeze(animatedSceneV230),freeze(animatedSceneV231),freeze(animatedSceneV232),freeze(animatedSceneV233),freeze(animatedSceneV234),freeze(animatedSceneV235),freeze(animatedSceneV236),freeze(editor),freeze(editorV210),freeze(editorV220),freeze(editorV221),freeze(editorV222),freeze(editorV223),freeze(editorV224),freeze(editorV225),freeze(editorV226),freeze(editorV227),freeze(pipelineV200),freeze(pipeline),freeze(pipelineV220),freeze(pipelineV221),freeze(pipelineV223),freeze(pipelineV222),freeze(shotPlanAssistant),freeze(animatedSceneAssistant),freeze(blueprintAssistant),freeze(whiteboardAssistant),freeze(qualityAudit)]);
|
|
205
|
+
export const FIRST_PARTY_SDK_PACKAGES = Object.freeze([freeze(productAdVariants),freeze(pickPhotos),freeze(photoNotesV110),freeze(photoNotes),freeze(youtubePublication),freeze(stockSearch),freeze(exportVideoV210),freeze(exportVideo),freeze(exportAudio),freeze(motionGraphics),freeze(audioScore),freeze(shortFormFinish),freeze(screenRecorder),freeze(screenRecorderV100),freeze(narration),freeze(narrationV100),freeze(shortFormFinishV102),freeze(shortFormFinishV101),freeze(shortFormFinishV100),freeze(occasionStory),freeze(occasionStoryV101),freeze(occasionStoryV100),freeze(releaseVisuals),freeze(releaseVisualsV101),freeze(releaseVisualsV100),freeze(photoReelComposer),freeze(photoReelComposerV101),freeze(photoReelComposerV100),freeze(importMediaV202),freeze(importMediaV201),freeze(editorV2212),freeze(editorV2211),freeze(editorV2210),freeze(editorV229),freeze(editorV228),freeze(panelStripV242),freeze(panelStripV241),freeze(panelStripV240),freeze(panelStripV237),freeze(panelStripV236),freeze(boardEditorV2211),freeze(boardEditorV2210),freeze(boardEditorV229),freeze(animatedSceneV239),freeze(animatedSceneV238),freeze(animatedSceneV237),freeze(pipelineV226),freeze(pipelineV225),freeze(pipelineV224),freeze(blueprintV219),freeze(blueprintV218),freeze(videoAssetsV214),freeze(videoAssetsV213),freeze(chatV203),freeze(chatV202),freeze(chatV201),freeze(shotPlanV221),freeze(shotPlanV220),freeze(shotPlanV213),freeze(shotPlanV212),freeze(voiceoverV216),freeze(voiceoverV215),freeze(voiceoverV214),freeze(voiceoverV213),freeze(scriptV215),freeze(scriptV214),freeze(scriptV213),freeze(roughCutV202),freeze(roughCutV201),freeze(finalEditV202),freeze(finalEditV201),freeze(editAssistantV202),freeze(editAssistantV201),freeze(gradeV202),freeze(gradeV201),freeze(transitionV202),freeze(transitionV201),freeze(qualityAuditV102),freeze(qualityAuditV101),freeze(compareV202),freeze(compareV201),freeze(compare),freeze(grade),freeze(transition),freeze(exportTimeline),freeze(captions),freeze(importMedia),freeze(generateImageV210),freeze(generateImage),freeze(thumbnailV201),freeze(thumbnail),freeze(writeSummary),freeze(automationV110),freeze(automationWhen),freeze(automationNewItems),freeze(automationAiDecide),freeze(automationContinueIf),freeze(connectApp),freeze(automationContactFields),freeze(automationAiWrite),freeze(automationMetaCreative),freeze(voiceover),freeze(voiceoverV210),freeze(voiceoverV211),freeze(voiceoverV212),freeze(script),freeze(scriptV210),freeze(scriptV211),freeze(scriptV212),freeze(generateVideoV201),freeze(generateVideo),freeze(chat),freeze(characterV201),freeze(character),freeze(locationV201),freeze(location),freeze(styleV201),freeze(style),freeze(researchV202),freeze(researchV201),freeze(research),freeze(imageEditorV201),freeze(imageEditor),freeze(describeMediaV210),freeze(describeMediaV201),freeze(describeMedia),freeze(videoAssets),freeze(videoAssetsV210),freeze(videoAssetsV211),freeze(videoAssetsV212),freeze(roughCut),freeze(finalEdit),freeze(editAssistant),freeze(transcribeV201),freeze(transcribe),freeze(blueprint),freeze(blueprintV210),freeze(blueprintV211),freeze(blueprintV212),freeze(blueprintV213),freeze(blueprintV214),freeze(blueprintV215),freeze(blueprintV216),freeze(blueprintV217),freeze(shotPlan),freeze(shotPlanV210),freeze(shotPlanV211),freeze(boardEditor),freeze(boardEditorV210),freeze(boardEditorV220),freeze(boardEditorV221),freeze(boardEditorV222),freeze(boardEditorV223),freeze(boardEditorV224),freeze(boardEditorV225),freeze(boardEditorV226),freeze(boardEditorV227),freeze(boardEditorV228),freeze(panelStripV200),freeze(panelStrip),freeze(panelStripV220),freeze(panelStripV230),freeze(panelStripV231),freeze(panelStripV232),freeze(panelStripV233),freeze(panelStripV234),freeze(panelStripV235),freeze(animatedSceneV200),freeze(animatedScene),freeze(animatedSceneV220),freeze(animatedSceneV230),freeze(animatedSceneV231),freeze(animatedSceneV232),freeze(animatedSceneV233),freeze(animatedSceneV234),freeze(animatedSceneV235),freeze(animatedSceneV236),freeze(editor),freeze(editorV210),freeze(editorV220),freeze(editorV221),freeze(editorV222),freeze(editorV223),freeze(editorV224),freeze(editorV225),freeze(editorV226),freeze(editorV227),freeze(pipelineV200),freeze(pipeline),freeze(pipelineV220),freeze(pipelineV221),freeze(pipelineV223),freeze(pipelineV222),freeze(shotPlanAssistant),freeze(animatedSceneAssistant),freeze(blueprintAssistant),freeze(whiteboardAssistant),freeze(qualityAudit)]);
|
|
200
206
|
export const firstPartySdkPackage = manifest => FIRST_PARTY_SDK_PACKAGES.find(pkg => pkg.manifest.id === manifest?.id && pkg.manifest.version === manifest?.version);
|
|
@@ -42,6 +42,7 @@ export const BLOCK_INTERFACE_PRINCIPLES=Object.freeze([
|
|
|
42
42
|
'Status sits beside the action it describes (`sm-status`), in a few words.',
|
|
43
43
|
'Keep the page in normal scroll flow; no sticky media or control strips unless the editing task needs them.',
|
|
44
44
|
'Use the width: from 860px wide the kit puts the controls on the left at a reading width (300–380px) and the result (`sm-stage`) fills the right, in its real shape; under that everything stacks with the result first and the action within reach. Extra width goes to the result, never to wider text fields. Before there is a result, the stage previews what will be made from the inputs. `data-layout="stack"` opts out.',
|
|
45
|
+
'Size with the kit, never fixed sizes: no fixed pixel widths or heights for the workspace or stage, no 100vh, no position:fixed panels. StillMade zooms a Block\'s screen to fill the space it has (up to 150%, never down to a phone layout on a computer), so a Block laid out with the kit\'s spacing fills its Step without empty space.',
|
|
45
46
|
'Settings sit side by side (`sm-settings`), next to what they change. Three or four short options are one segmented row (`data-segmented`), not a row of big tiles.',
|
|
46
47
|
'Lists fill the width at a small size: input files are small tiles (`sm-gallery data-size="small"`), many items are one line each (`sm-list`): a thumbnail, one line of text, a short fact. Tapping an item opens all of its fields in place. Extra width means more items per row, never bigger cards.',
|
|
47
48
|
'On a phone chip and segment rows scroll sideways instead of wrapping; long lists show their first three with "N more" (`data-peek`) and long text five lines until tapped (`sm-clamp`).',
|
|
@@ -135,6 +136,58 @@ function topLevelRows(html){
|
|
|
135
136
|
return rows;
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
// November 1, 2026 (owner, zoom plan): a screen held to a narrow width leaves most of a wide Step empty, and StillMade's
|
|
140
|
+
// zoom cannot fill it (it stops before the kit's two-column layout from 860px would change). The narrowest fixed or
|
|
141
|
+
// maximum width on what holds the whole screen (html, body, the outermost element, a lone wrapper inside it), from its
|
|
142
|
+
// inline style or a stylesheet rule outside a small-screen @media block; null when nothing holds it.
|
|
143
|
+
const KIT_TWO_COLUMNS=860;
|
|
144
|
+
function narrowScreen(view){
|
|
145
|
+
const html=String(view?.html||''),lower=html.toLowerCase(),holders=[{tag:'html',classes:[],id:''},{tag:'body',classes:[],id:''}];
|
|
146
|
+
const width=declarations=>{let narrowest=null;for(const match of String(declarations).matchAll(/(?:^|[;{\s])(?:max-width|width)\s*:\s*(\d{1,5}(?:\.\d+)?)(px|rem|em)\b/gi)){const px=Number(match[1])*(match[2].toLowerCase()==='px'?1:16);if(px>=200&&px<KIT_TWO_COLUMNS)narrowest=Math.min(narrowest??Infinity,px);}return narrowest;};
|
|
147
|
+
// The outermost elements, and the children of a lone outermost one; a lone child is a wrapper too.
|
|
148
|
+
// The last '>' found is reused while it is still ahead, and attributes are read from a bounded slice, so broken tags
|
|
149
|
+
// ("<a<a<a…>") stay linear.
|
|
150
|
+
const top=[],inner=[];let depth=0,end=-1;
|
|
151
|
+
for(let at=lower.indexOf('<');at!==-1;at=lower.indexOf('<',at+1)){
|
|
152
|
+
if(end<at)end=lower.indexOf('>',at);if(end===-1)break;
|
|
153
|
+
const tag=/^<(\/?)([a-z][a-z0-9-]*)/.exec(lower.slice(at,Math.min(end+1,at+40)));
|
|
154
|
+
if(!tag)continue;
|
|
155
|
+
const [,closing,name]=tag;
|
|
156
|
+
if(closing){depth=Math.max(0,depth-1);if(depth===0&&top.length>1)break;continue;}
|
|
157
|
+
if(['script','style','template'].includes(name)){const close=lower.indexOf(`</${name}`,end);if(close===-1)break;at=close;continue;}
|
|
158
|
+
const attributes=html.slice(at,Math.min(end,at+2000)),element={tag:name,classes:(/\sclass\s*=\s*["']([^"']*)["']/i.exec(attributes)?.[1]||'').split(/\s+/).filter(Boolean),id:/\sid\s*=\s*["']([^"']*)["']/i.exec(attributes)?.[1]||'',style:/\sstyle\s*=\s*["']([^"']*)["']/i.exec(attributes)?.[1]||''};
|
|
159
|
+
if(depth===0)top.push(element);else if(depth===1&&top.length===1)inner.push(element);
|
|
160
|
+
if(!VOID_TAGS.has(name)&&lower[end-1]!=='/')depth++;
|
|
161
|
+
}
|
|
162
|
+
holders.push(...top.filter(()=>top.length===1),...(inner.length===1?inner:[]));
|
|
163
|
+
for(const element of holders.slice(2)){const px=width(element.style);if(px)return {px,where:`the style on <${element.tag}>`};}
|
|
164
|
+
const holds=selector=>{
|
|
165
|
+
const last=selector.trim().split(/[\s>+~]+/).pop().replace(/^:root$/i,'html'),parts=/^([a-z][a-z0-9-]*|\*)?((?:[.#][\w-]+)*)$/i.exec(last);
|
|
166
|
+
if(!parts||!parts[1]&&!parts[2])return false;
|
|
167
|
+
const classes=[...parts[2].matchAll(/\.([\w-]+)/g)].map(match=>match[1]),id=/#([\w-]+)/.exec(parts[2])?.[1]||'';
|
|
168
|
+
return holders.some(element=>(!parts[1]||parts[1]==='*'||parts[1].toLowerCase()===element.tag)&&classes.every(name=>element.classes.includes(name))&&(!id||id===element.id));
|
|
169
|
+
};
|
|
170
|
+
// One pass over the stylesheet, comments skipped: each rule with the @media blocks around it.
|
|
171
|
+
const css=String(view?.css||''),around=[];let prelude='',narrowest=null;
|
|
172
|
+
for(let index=0;index<css.length;index++){
|
|
173
|
+
const char=css[index];
|
|
174
|
+
if(char==='/'&&css[index+1]==='*'){const close=css.indexOf('*/',index+2);if(close===-1)break;index=close+1;continue;}
|
|
175
|
+
if(char==='{'){
|
|
176
|
+
const head=prelude.trim();prelude='';
|
|
177
|
+
if(head.startsWith('@')){around.push(head);continue;}
|
|
178
|
+
const close=css.indexOf('}',index);if(close===-1)break;
|
|
179
|
+
const smallOnly=around.some(rule=>/^@media\b/i.test(rule)&&/max-width/i.test(rule));
|
|
180
|
+
const px=!smallOnly&&head.split(',').some(holds)?width(css.slice(index+1,close)):null;
|
|
181
|
+
if(px&&(!narrowest||px<narrowest.px))narrowest={px,where:head.slice(0,60)};
|
|
182
|
+
index=close;continue;
|
|
183
|
+
}
|
|
184
|
+
if(char==='}'){around.pop();prelude='';continue;}
|
|
185
|
+
if(char===';'&&!around.length){prelude='';continue;}
|
|
186
|
+
prelude+=char;
|
|
187
|
+
}
|
|
188
|
+
return narrowest;
|
|
189
|
+
}
|
|
190
|
+
|
|
138
191
|
export function reviewViewDesign(view){
|
|
139
192
|
if(!view)return {passed:true,issues:[],warnings:[],principles:BLOCK_INTERFACE_PRINCIPLES};
|
|
140
193
|
const html=view.html||'',javascript=view.javascript||'',primary=html.replace(/<details\b[\s\S]*?<\/details>/gi,'');
|
|
@@ -157,6 +210,8 @@ export function reviewViewDesign(view){
|
|
|
157
210
|
if(rareTool)warnings.push(`"${rareTool.slice(0,40)}" is its own section. Rare tools (update, schedule, history, reload) are named icon buttons beside the action (.sm-icon in .sm-actions), never sections under the work.`);
|
|
158
211
|
const rows=topLevelRows(primary);
|
|
159
212
|
if(rows>COMPACT_ROW_LIMIT)warnings.push(`The workspace has ${rows} visible rows besides the stage and may not fit one laptop screen (an estimate: this check counts rows and cannot measure height). Put short settings side by side in .sm-settings and secondary ones in a closed .sm-more.`);
|
|
213
|
+
const narrow=narrowScreen(view);
|
|
214
|
+
if(narrow)warnings.push(`The screen is held to ${Math.round(narrow.px)}px wide (${narrow.where}), so a wide Step shows mostly empty space, and StillMade's zoom cannot fill it. Drop the fixed or maximum width: the Block kit lays out the controls and the result across the width.`);
|
|
160
215
|
warnings.push(...blockKitProblems(view).map(problem=>problem.message));
|
|
161
216
|
return {passed:issues.length===0,issues,warnings,principles:BLOCK_INTERFACE_PRINCIPLES};
|
|
162
217
|
}
|