stillmade-sdk 0.1.37 → 0.1.39
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/MODULES.md +29 -11
- package/PASTE_TO_LLM.md +7 -6
- package/SDK.md +16 -15
- package/UI_AESTHETIC.md +6 -0
- 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-cli/cli.js +28 -17
- package/packages/block-cli/dev-host/assets/host.js +16 -16
- package/packages/block-cli/dev-host/assets/index.css +1 -1
- package/packages/block-cli/dev-server.js +14 -2
- package/packages/block-cli/ready-gate.js +3 -2
- package/packages/block-platform/catalog.js +1 -1
- package/packages/block-platform/motion-starters.js +14 -0
- package/packages/block-platform/project-type-starters.js +20 -7
- package/packages/block-sdk/capabilities.js +1 -1
- package/packages/block-sdk/example-media-data.js +2 -1
- package/packages/block-sdk/example-media.js +1 -0
- package/packages/block-sdk/first-party-identities.js +6 -0
- package/packages/block-sdk/first-party-packages.js +7 -1
- package/packages/block-sdk/media-transform.js +17 -4
- package/packages/block-sdk/mock-host.js +24 -5
- package/packages/block-sdk/module-hosted.js +12 -4
- package/packages/block-sdk/semantic-relationships.js +6 -0
- package/packages/block-sdk/testing.js +5 -3
- package/packages/block-sdk/view-design.js +56 -1
|
@@ -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
|
@@ -21,7 +21,7 @@ import {realpathSync} from 'node:fs';
|
|
|
21
21
|
import {numberedTakes} from '../block-sdk/state-example.js';
|
|
22
22
|
import {initialBlockState,validateBlockState} from '../block-sdk/state.js';
|
|
23
23
|
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
24
|
-
import { resolve, join, sep, dirname } from 'node:path';
|
|
24
|
+
import { resolve, join, sep, dirname, basename } from 'node:path';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
26
|
import { textCleanup, lineArt, javascriptText } from '../block-sdk/examples.js';
|
|
27
27
|
import {validateViewAppearance} from '../block-sdk/appearance.js';
|
|
@@ -84,20 +84,20 @@ const localFileReader=folder=>async name=>{
|
|
|
84
84
|
const info=await lstat(full);if(!info.isFile())throw Object.assign(new Error(`${name} is not a file.`),{code:'MODULE_FILE_READ'});
|
|
85
85
|
return new Uint8Array(await readFile(full));
|
|
86
86
|
};
|
|
87
|
-
async function standIns(path,pkg,requested){
|
|
87
|
+
async function standIns(path,pkg,requested,pickedMedia){
|
|
88
88
|
let mocks;const folder=await blockFolder(path);
|
|
89
89
|
if(folder){try{mocks=JSON.parse(await readFile(join(folder,MOCKS_FILE),'utf8'));}catch(error){if(error.code!=='ENOENT')throw Object.assign(new Error(`${MOCKS_FILE}: ${error.message}`),{code:'MOCK_HOST'});}}
|
|
90
90
|
if(!requested&&mocks===undefined)return null;
|
|
91
|
-
return createMockHost({mocks:validateMocks(mocks),readLocalFile:localFileReader(folder),manifest:pkg.manifest});
|
|
91
|
+
return createMockHost({mocks:validateMocks(mocks),readLocalFile:localFileReader(folder),manifest:pkg.manifest,pickedMedia});
|
|
92
92
|
}
|
|
93
93
|
// A module fixture's own mocks take the place of tests/mocks.json for that fixture (MODULES.md), with or
|
|
94
94
|
// without --mock, as ready and StillMade's import review already did (October 27, 2026: test, pack and dev
|
|
95
95
|
// answered every fixture from tests/mocks.json, or left it unanswered when there was none).
|
|
96
96
|
// Returns hostFor(mocks), a fresh stand-in host for one fixture, or null for other runtimes.
|
|
97
|
-
async function fixtureStandIns(path,pkg){
|
|
97
|
+
async function fixtureStandIns(path,pkg,pickedMedia){
|
|
98
98
|
if(pkg.manifest?.runtime!=='module')return null;
|
|
99
99
|
const readLocalFile=localFileReader(await blockFolder(path));
|
|
100
|
-
return mocks=>createMockHost({mocks:validateMocks(mocks),readLocalFile,manifest:pkg.manifest});
|
|
100
|
+
return mocks=>createMockHost({mocks:validateMocks(mocks),readLocalFile,manifest:pkg.manifest,pickedMedia});
|
|
101
101
|
}
|
|
102
102
|
// A run whose input is a fixture's (dev's Example picker, run without an input file) uses that fixture's mocks.
|
|
103
103
|
const fixtureFor=(pkg,input)=>pkg.manifest?.runtime==='module'?pkg.tests?.find(test=>test?.mocks&&stableStringify(test.input)===stableStringify(input)):undefined;
|
|
@@ -125,13 +125,17 @@ async function dev(path,options){
|
|
|
125
125
|
if(!(await lstat(path)).isDirectory())throw Object.assign(new Error('dev previews a Block folder.'),{code:'USAGE'});
|
|
126
126
|
const {startDevServer}=await import('./dev-server.js');
|
|
127
127
|
let mock,mocksText,memory,answered;
|
|
128
|
+
// Files the person picks in the preview's own-file mode: kept in this process only, read by the run as themselves
|
|
129
|
+
// (November 1, 2026, owner: try a Block on your own video, not only its example).
|
|
130
|
+
const picked=new Map(),pickedMedia=url=>picked.get(url)||null;
|
|
131
|
+
const pick=(bytes,{mediaType,name})=>{const id=randomBytes(8).toString('hex'),safe=String(name||'file').replace(/[^\w.-]+/g,'-').slice(0,80)||'file',url=`/api/media/picked/${id}/${safe}`;picked.set(url,{bytes,mediaType});return url;};
|
|
128
132
|
const load=async()=>{
|
|
129
133
|
let pkg;
|
|
130
134
|
try{pkg=await readPackage(path);}catch(error){return {problem:`${error.message}${error.code?` (${error.code})`:''}`};}
|
|
131
135
|
let problem=null;
|
|
132
136
|
try{validatePackage(pkg);appearanceAdvice(pkg.view);}catch(error){problem=error.message;}
|
|
133
137
|
let text=null;try{text=await readFile(join(path,MOCKS_FILE),'utf8');}catch(error){if(error.code!=='ENOENT')problem=`${MOCKS_FILE}: ${error.message}`;}
|
|
134
|
-
if(!mock||text!==mocksText){try{mock=await standIns(path,pkg,true);mocksText=text;}catch(error){problem=error.message;}}
|
|
138
|
+
if(!mock||text!==mocksText){try{mock=await standIns(path,pkg,true,pickedMedia);mocksText=text;}catch(error){problem=error.message;}}
|
|
135
139
|
memory=undefined;
|
|
136
140
|
const files=moduleFiles.get(pkg)||{},filesBySha={};
|
|
137
141
|
for(const [name,file] of [...Object.entries(pkg.module?.files||{}),...Object.entries(pkg.view?.files?.files||{}).map(([name,file])=>[`view:${name}`,file])])if(files[name])filesBySha[file.sha256]=files[name];
|
|
@@ -140,7 +144,7 @@ async function dev(path,options){
|
|
|
140
144
|
const run=async(pkg,input)=>{
|
|
141
145
|
const state=pkg.manifest.state?memory??initialBlockState(pkg.manifest):undefined;
|
|
142
146
|
// The Example picker sends that example's input: unedited, it runs against the example's own mocks.
|
|
143
|
-
const fixture=fixtureFor(pkg,input),own=fixture?(await fixtureStandIns(path,pkg))(fixture.mocks):null;
|
|
147
|
+
const fixture=fixtureFor(pkg,input),own=fixture?(await fixtureStandIns(path,pkg,pickedMedia))(fixture.mocks):null;
|
|
144
148
|
answered=own?{host:own,files:fixture.mocks.files}:null;
|
|
145
149
|
const result=await runPackageAsync(pkg,input,{...(state?{state}:{}),...(pkg.manifest.runtime==='module'?{module:moduleExecutor(pkg,(own||mock)?.host)}:{}),...(pkg.manifest.runtime==='capability'&&mock?{capability:mock.capability}:{})});
|
|
146
150
|
if(pkg.manifest.state)memory=result.state;
|
|
@@ -148,7 +152,7 @@ async function dev(path,options){
|
|
|
148
152
|
return {outputs:result.outputs,...(result.logs?{logs:result.logs}:{}),...(calls.length?{standIns:calls}:{}),...(result.state!==undefined?{state:result.state}:{})};
|
|
149
153
|
};
|
|
150
154
|
const media=async url=>{
|
|
151
|
-
const made=answered?.host.file(url)||mock?.file(url);if(made)return made;
|
|
155
|
+
const made=pickedMedia(url)||answered?.host.file(url)||mock?.file(url);if(made)return made;
|
|
152
156
|
let mocks={};try{mocks=JSON.parse(mocksText||'{}');}catch{}
|
|
153
157
|
const local=answered?.files?.[url]??mocks.files?.[url];if(typeof local!=='string')return null;
|
|
154
158
|
const full=resolve(path,local);if(!full.startsWith(path+sep))return null;
|
|
@@ -159,7 +163,7 @@ async function dev(path,options){
|
|
|
159
163
|
?{fileName:`${pkg.manifest.id}-${pkg.manifest.version}${MODULE_BUNDLE_SUFFIX}`,source:JSON.stringify(createModuleBundle(pkg,moduleFiles.get(pkg)))}
|
|
160
164
|
:{fileName:`${pkg.manifest.id}-${pkg.manifest.version}.stillmade.json`,source:JSON.stringify(pkg)};
|
|
161
165
|
const link=live?{origin:liveSite(site),key:randomBytes(24).toString('base64url')}:undefined;
|
|
162
|
-
const server=await startDevServer({folder:path,port,build,load,run,media,live:link,bundle});
|
|
166
|
+
const server=await startDevServer({folder:path,port,build,load,run,media,pick,live:link,bundle});
|
|
163
167
|
const liveUrl=link&&`${link.origin}/create/import?live=${encodeURIComponent(`${server.url}/__live/${link.key}`)}`;
|
|
164
168
|
return {dev:server.url,folder:path,...(build?{build}:{}),...(liveUrl?{live:liveUrl}:{}),close:server.close};
|
|
165
169
|
}
|
|
@@ -205,7 +209,11 @@ export async function main(rawArgs) {
|
|
|
205
209
|
if(args.length<2)throw Object.assign(new Error('Name the folder to create: stillmade-block create my-block [template]'),{code:'USAGE'});
|
|
206
210
|
const template=templateNamed(extra),files=await template.files({folder:path});
|
|
207
211
|
// Every new Block starts with a feature map (one entry from its name and description) for the builder to rewrite as it grows.
|
|
208
|
-
{const created=JSON.parse(files['stillmade.block.json']);if(!created.features&&!created.stages){created.features=blockFeatures(created).map(({source,...feature})=>feature);files['stillmade.block.json']=`${JSON.stringify(created,null,2)}\n`;}
|
|
212
|
+
{const created=JSON.parse(files['stillmade.block.json']);if(!created.features&&!created.stages){created.features=blockFeatures(created).map(({source,...feature})=>feature);files['stillmade.block.json']=`${JSON.stringify(created,null,2)}\n`;}
|
|
213
|
+
// The new Block is named for its folder, never the example it started from (November 1, 2026: a no-context agent's
|
|
214
|
+
// social-media-clip-maker kept the id example.poster-from-idea).
|
|
215
|
+
const slug=basename(path).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^[^a-z]+|-+$/g,'').slice(0,60);
|
|
216
|
+
if(!created.stages&&typeof created.id==='string'&&created.id.startsWith('example.')&&slug){created.id=`creator.${slug}`;files['stillmade.block.json']=`${JSON.stringify(created,null,2)}\n`;}}
|
|
209
217
|
// Editors pick up the generated types for the view, src/run.js and module code.
|
|
210
218
|
files['stillmade-env.d.ts']=blockTypeDeclarations(JSON.parse(files['stillmade.block.json']));
|
|
211
219
|
files['jsconfig.json']=`${JSON.stringify({compilerOptions:{target:'ES2022',module:'ESNext',moduleResolution:'Bundler',lib:['ES2022','DOM'],jsx:'react-jsx',checkJs:false},include:['src','module','stillmade-env.d.ts']},null,2)}\n`;
|
|
@@ -295,14 +303,14 @@ export async function main(rawArgs) {
|
|
|
295
303
|
if(!scan.passed)throw Object.assign(new Error(`${dependency.manifest.name}: Static validation failed`),{report:scan});
|
|
296
304
|
}
|
|
297
305
|
const output=jsonOutput(extra,`${pkg.id}-${pkg.version}.stillmade.json`);
|
|
298
|
-
await writeFile(output,JSON.stringify(pkg,null,2),{flag:
|
|
306
|
+
await writeFile(output,JSON.stringify(pkg,null,2),{flag:packFlag(extra)});
|
|
299
307
|
return {file:output,digest:await digest(pkg),tests:0,...review,message:'Project Type source packaged without execution. In StillMade, review every embedded hosted Block in StillMade (including ComfyUI backend fixtures and samples), then confirm the import after local admission checks. The connected workflow has not run.'};
|
|
300
308
|
}
|
|
301
309
|
const report = await testProjectType(pkg);
|
|
302
310
|
if (!report.passed) throw Object.assign(new Error('Project Type validation or execution checks need attention'), { report:{...report,...(hosted ? {reviewRequired:true,liveVerified:false} : {})} });
|
|
303
311
|
if (command === 'test') return report;
|
|
304
312
|
const output = jsonOutput(extra,`${pkg.id}-${pkg.version}.stillmade.json`);
|
|
305
|
-
await writeFile(output, JSON.stringify(pkg, null, 2), { flag:
|
|
313
|
+
await writeFile(output, JSON.stringify(pkg, null, 2), { flag: packFlag(extra) });
|
|
306
314
|
return { file: output, digest: report.digest, tests: (pkg.packages || []).reduce((count,dependency)=>count+dependency.tests.length,0), workflowComplete:report.workflowComplete, workflowStatus:report.workflowStatus };
|
|
307
315
|
}
|
|
308
316
|
// permissions: what the Block can do in plain words, and what its source uses that
|
|
@@ -330,7 +338,7 @@ export async function main(rawArgs) {
|
|
|
330
338
|
const report=await admitLocalPackage(pkg,{files:moduleFiles.get(pkg),moduleExecution:true});if(!report.passed)throw Object.assign(new Error('Portable admission failed'),{report});
|
|
331
339
|
const output=resolve(extra||`${pkg.manifest.id}-${pkg.manifest.version}.stillmade-block`);
|
|
332
340
|
if(!output.endsWith('.stillmade-block'))throw new Error('Canonical output must end in .stillmade-block');
|
|
333
|
-
await writeFile(output,writePortableArchive(pkg,archiveCodec()),{flag:
|
|
341
|
+
await writeFile(output,writePortableArchive(pkg,archiveCodec()),{flag:packFlag(extra)});return {file:output,digest:report.digest,tests:hosted?0:pkg.tests.length,format:pkg.portable.format,admission:report,repositoryEvidence:evidence,...(hosted?{fixtureContracts:pkg.tests.length,execution:'not-run',liveVerified:false,reviewRequired:true}:{})};
|
|
334
342
|
}
|
|
335
343
|
}
|
|
336
344
|
const advice=command !== 'preview'?appearanceAdvice(pkg.view):null;
|
|
@@ -367,14 +375,14 @@ export async function main(rawArgs) {
|
|
|
367
375
|
const report=await admitLocalPackage(pkg);if(!report.passed)throw Object.assign(new Error('Desktop source admission failed'),{report});
|
|
368
376
|
if(command==='test')return report;
|
|
369
377
|
const output=jsonOutput(extra,`${pkg.manifest.id}-${pkg.manifest.version}.stillmade.json`);
|
|
370
|
-
await writeFile(output,JSON.stringify(pkg,null,2),{flag:
|
|
378
|
+
await writeFile(output,JSON.stringify(pkg,null,2),{flag:packFlag(extra)});
|
|
371
379
|
return {file:output,digest:report.digest,tests:0,fixtureContracts:pkg.tests.length,execution:'not-run',liveVerified:false,reviewRequired:true,admission:report,message:'Source packaged without native execution. Run and review the declared action through its approved StillMade Desktop adapter.'};
|
|
372
380
|
}
|
|
373
381
|
if(command === 'pack' && ['comfyui','capability'].includes(pkg.manifest.runtime)) {
|
|
374
382
|
const scan=scanPackage(pkg);
|
|
375
383
|
if(!scan.passed)throw Object.assign(new Error('Static validation failed'),{report:scan});
|
|
376
384
|
const output=jsonOutput(extra,`${pkg.manifest.id}-${pkg.manifest.version}.stillmade.json`);
|
|
377
|
-
await writeFile(output,JSON.stringify(pkg,null,2),{flag:
|
|
385
|
+
await writeFile(output,JSON.stringify(pkg,null,2),{flag:packFlag(extra)});
|
|
378
386
|
return {file:output,digest:await digest(pkg),tests:0,reviewRequired:true,...(pkg.manifest.runtime==='capability'?{liveVerified:false}:{}),message:pkg.manifest.runtime==='capability'?'Source packaged without provider execution. Import into StillMade, choose a model and payment method, and approve the fixtures and sample before confirming installation.':'Source packaged. Import into StillMade to run all fixtures and preview on your connected ComfyUI backend before confirming installation.'};
|
|
379
387
|
}
|
|
380
388
|
const mock=['module','capability'].includes(pkg.manifest.runtime)?await standIns(path,pkg,mockRequested):null,hostFor=await fixtureStandIns(path,pkg),own=[];
|
|
@@ -394,13 +402,16 @@ export async function main(rawArgs) {
|
|
|
394
402
|
if (hasDigestFiles(pkg)) {
|
|
395
403
|
const files=moduleFiles.get(pkg),output=resolve(extra || `${pkg.manifest.id}-${pkg.manifest.version}${MODULE_BUNDLE_SUFFIX}`);
|
|
396
404
|
if(!output.endsWith(MODULE_BUNDLE_SUFFIX))throw new Error(`Module output must end in ${MODULE_BUNDLE_SUFFIX}`);
|
|
397
|
-
await writeFile(output,JSON.stringify(createModuleBundle(pkg,files)),{flag:
|
|
405
|
+
await writeFile(output,JSON.stringify(createModuleBundle(pkg,files)),{flag:packFlag(extra)});
|
|
398
406
|
return {file:output,digest:report.digest,tests:report.results.length,files:Object.keys(pkg.module?.files||{}).length+Object.keys(pkg.view?.files?.files||{}).length,...standInNote(mock,own),message:'Import this bundle in StillMade (Create, then Import). StillMade checks every file digest and uploads the files once before saving the Block.'};
|
|
399
407
|
}
|
|
400
408
|
const output = jsonOutput(extra,`${pkg.manifest.id}-${pkg.manifest.version}.stillmade.json`);
|
|
401
|
-
await writeFile(output, JSON.stringify(pkg, null, 2), { flag:
|
|
409
|
+
await writeFile(output, JSON.stringify(pkg, null, 2), { flag: packFlag(extra) });
|
|
402
410
|
return { file: output, digest: report.digest, tests: report.results.length };
|
|
403
411
|
}
|
|
412
|
+
// pack replaces the file it wrote before under its own name, so packing again after a fix just works; an output path the
|
|
413
|
+
// person names is never overwritten (November 1, 2026: a no-context agent's second pack failed with EEXIST).
|
|
414
|
+
function packFlag(output){return output?'wx':'w';}
|
|
404
415
|
// How many fixtures admission ran and passed, for the ready and gate reports.
|
|
405
416
|
function fixtureCounts(pkg,admission){
|
|
406
417
|
const rows=(admission.results||[]).filter(row=>/ contract test: /.test(row.name)),failed=rows.filter(row=>!row.passed);
|