stillmade-sdk 0.1.16 → 0.1.18
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/.stillmade/sdk-baseline.json +3 -3
- package/DESKTOP_CAPABILITIES.md +17 -7
- package/SDK.md +19 -8
- package/blocks/screen-recorder/package.js +1 -0
- package/blocks/screen-recorder/versions/1.0.0/package.js +223 -0
- package/examples/camera-recorder.stillmade.json +4 -5
- package/examples/microphone-recorder.stillmade.json +4 -5
- package/examples/screen-recorder.stillmade.json +4 -5
- package/package.json +1 -1
- package/packages/block-cli/dev-host/assets/host.js +101 -18
- package/packages/block-cli/dev-server.js +1 -1
- package/packages/block-platform/block-starters.js +3 -3
- package/packages/block-platform/flow-agent-pages.js +1 -1
- package/packages/block-platform/flow-inspection.js +4 -1
- package/packages/block-platform/plain-descriptions.js +1 -0
- package/packages/block-sdk/desktop-example.js +2 -2
- package/packages/block-sdk/first-party-identities.js +1 -0
- package/packages/block-sdk/first-party-packages.js +2 -1
- package/packages/block-sdk/view-bridge.js +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": 1,
|
|
3
3
|
"packages": {
|
|
4
4
|
"examples/asset-metadata.stillmade.json": "eb47dbde4d3d6857c829c223d56de9f76a3b1af5dffeff2f0ca433ba4678089b",
|
|
5
|
-
"examples/camera-recorder.stillmade.json": "
|
|
5
|
+
"examples/camera-recorder.stillmade.json": "8d8224b319e94e4afec3551445404ae99eae8c4d6df176ca7538bb0472f426b6",
|
|
6
6
|
"examples/caption-from-text.stillmade.json": "3731a15b59043fbebee98416f2474800ef0154aa7873fb494934874ddcd2c705",
|
|
7
7
|
"examples/captions.stillmade-block": "b0fde32dddaf866f3e540018309aaa00ae56b0e74a53a498fbaf1553ce200bd6",
|
|
8
8
|
"examples/check-clip.stillmade.json": "cf83af5a3317cd714282939f6cd40cd0c459fb5af97e97e5148f1ca81b4b1b87",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"examples/import-media.stillmade-block": "e9642e203080f1a145bec81871525a670f98dd1c8763862f81ab876fb15a656b",
|
|
28
28
|
"examples/javascript-cloze-quiz": "b36a370c8b53250b9bb04783591661268ec674052bc13bf8cc8910fa27151f83",
|
|
29
29
|
"examples/javascript-invoice": "853f1464393563ac6e330f4d90cd97f6768d753cbd26b3191bb0838506530ffd",
|
|
30
|
-
"examples/microphone-recorder.stillmade.json": "
|
|
30
|
+
"examples/microphone-recorder.stillmade.json": "cdb3c31a26f799bf190a85e37f44ee3726b820634b03d062c22da3189fbf64ce",
|
|
31
31
|
"examples/module-action-items": "ba6026bddb7efbd5fc3ccbab994fab435917723e2668a395feb5c98f5a68b1d1",
|
|
32
32
|
"examples/module-audio-extract": "5afc1ef4a5d6594e312acdaf7e171307a56ad60a20ca28b7cfa25dfc579cc8ab",
|
|
33
33
|
"examples/module-brand-kit": "cff0dd8b86f8a63a2ff52f192be275fa77794d8e0a6546b0923f31cec4e0ddbd",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"examples/saas-product-launch.stillmade.json": "aabec7d8680ef6687980296ab170bbb002c0dc81bc15edebdf9db9750e43eddb",
|
|
51
51
|
"examples/scene-shots.stillmade.json": "d10a0aebb42ff090035410dc92ab708ffaf50b579edac4aa2f2cf6150b3ef537",
|
|
52
52
|
"examples/score-a-scene.stillmade.json": "ecc05642d82d2507803389350c7ea574c4c3f761dd629289e0c1c8fa28713292",
|
|
53
|
-
"examples/screen-recorder.stillmade.json": "
|
|
53
|
+
"examples/screen-recorder.stillmade.json": "f3509a577cdd6615d6687ed0be108ddef51bf1f79ff0e2176470e46c3054640d",
|
|
54
54
|
"examples/script-draft.stillmade.json": "6c62787353c361ed2e9e255eb03047093ebdc4d678cfeb8be97b8b36e27b4102",
|
|
55
55
|
"examples/shot-readiness.stillmade.json": "ed73fe9038afaed7c12435368e90001f0bd3cebd8f0e6fe06508c1bb80c8cb7e",
|
|
56
56
|
"examples/summarize-page.stillmade.json": "fe2956a76d99d5cb10de69b763649064991ac9ac00a8e216db852a725be3e65e",
|
package/DESKTOP_CAPABILITIES.md
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
# Desktop capabilities for Blocks
|
|
2
2
|
|
|
3
|
+
Recording runs on both platforms: in a browser the trusted web host records with the browser's own screen, camera or microphone picker and keeps the finished file on the device (a `sm-media://device/...` reference); in StillMade Desktop the native host gives consent and picks the source. Every other scope below needs StillMade Desktop.
|
|
4
|
+
|
|
3
5
|
The desktop host implements these capabilities on demand. Importing a Block, installing StillMade, or opening a preview does not request device permissions. The first operation displays StillMade's native consent dialog identifying the Block ID and version. An OS prompt may follow. Grants last for the mounted Block workspace, up to one hour; closing/reloading it or revoking access ends that grant. Imported UI receives neither native IPC nor grant tokens.
|
|
4
6
|
|
|
5
7
|
Declare the exact required scopes in `manifest.permissions.desktop`. Other permission families remain unchanged. Pure recipe/JavaScript execution stays sandboxed; the **Block UI bridge** requests desktop operations through the trusted host.
|
|
6
8
|
|
|
7
9
|
| Scope | UI operation | Behavior |
|
|
8
10
|
| --- | --- | --- |
|
|
9
|
-
| `screen.capture` | `record.start`, `{kind:"screen"}` | Choose a screen or window, then record it. |
|
|
11
|
+
| `screen.capture` | `record.start`, `{kind:"screen"}` | Choose a screen or window, then record it. Browser and desktop. |
|
|
10
12
|
| `cursor.track` | `cursor.track`, or `{cursor:true}` on `record.start` | Global cursor coordinates and display geometry; recordings sample at 10 Hz. Does not monitor clicks or keyboard input. |
|
|
11
|
-
| `camera.capture` | `record.start`, `{kind:"camera"}` | Camera recording after device/OS approval. |
|
|
12
|
-
| `microphone.capture` | `record.start`, `{kind:"microphone"}` | Microphone recording after device/OS approval. |
|
|
13
|
+
| `camera.capture` | `record.start`, `{kind:"camera"}` | Camera recording after device/OS approval. Browser and desktop. |
|
|
14
|
+
| `microphone.capture` | `record.start`, `{kind:"microphone"}`, or `{kind:"screen", microphone:true}` | Microphone recording after device/OS approval, or the microphone mixed into a screen recording. Browser and desktop. |
|
|
13
15
|
| `clipboard.read` | `clipboard.read` | Returns `{text}`; bounded to 100,000 characters. |
|
|
14
16
|
| `clipboard.write` | `clipboard.write`, `{text}` | Explicit permission to replace clipboard text. |
|
|
15
17
|
| `media.pick` | `media.pick` | Native picker returns `{assets}` for up to 20 selected media files; no arbitrary path access. |
|
|
@@ -17,7 +19,7 @@ Declare the exact required scopes in `manifest.permissions.desktop`. Other permi
|
|
|
17
19
|
| `power.keep-awake` | `power.keep-awake`, `{enabled:true}` | Prevent app suspension until disabled, revoked, closed, or expired. |
|
|
18
20
|
| `native.tools` | `tool.status` and declared `tool.*` operations | Use one exact versioned adapter for an approved installed desktop application. |
|
|
19
21
|
|
|
20
|
-
The host always checks declarations. Declaring a scope does not grant it.
|
|
22
|
+
The host always checks declarations. Declaring a scope does not grant it. For anything but recording, a browser receives a clear desktop-required error; there is no silent paid/cloud fallback. A Block whose only desktop scopes are recording scopes is not marked as requiring StillMade Desktop.
|
|
21
23
|
|
|
22
24
|
## Approved native desktop tools
|
|
23
25
|
|
|
@@ -37,7 +39,7 @@ The mapped Natron Block uses `video` as both its primary typed input and output,
|
|
|
37
39
|
|
|
38
40
|
## Ready-to-import recording Blocks
|
|
39
41
|
|
|
40
|
-
Download the complete [Screen recorder](/block-sdk/examples/screen-recorder.stillmade.json), [Camera recorder](/block-sdk/examples/camera-recorder.stillmade.json), or [Microphone recorder](/block-sdk/examples/microphone-recorder.stillmade.json). These packages also ship in the SDK archive. Import one through StillMade’s normal review flow, then use
|
|
42
|
+
Download the complete [Screen recorder](/block-sdk/examples/screen-recorder.stillmade.json), [Camera recorder](/block-sdk/examples/camera-recorder.stillmade.json), or [Microphone recorder](/block-sdk/examples/microphone-recorder.stillmade.json). These packages also ship in the SDK archive. Import one through StillMade’s normal review flow, then use it in a browser or in the desktop app. Each includes a themed interface and pure runtime fixtures that preserve the media reference. The fixtures use synthetic references; they do not exercise your device.
|
|
41
43
|
|
|
42
44
|
The interface saves recording media before sending it through the runtime. If that handoff fails, Retry uses the saved recording without recording again.
|
|
43
45
|
|
|
@@ -49,6 +51,8 @@ Manifest fragment:
|
|
|
49
51
|
{"permissions":{"project":[],"network":[],"filesystem":[],"secrets":[],"desktop":["screen.capture","cursor.track"]}}
|
|
50
52
|
```
|
|
51
53
|
|
|
54
|
+
`record.start` takes `{kind, microphone, systemAudio, resolution, frameRate, cursor}`: `kind` is `screen` (default), `camera` or `microphone`; `microphone:true` mixes the microphone into a screen recording (declare `microphone.capture` too); `systemAudio:true` asks for the shared audio (Windows loopback on desktop, the browser's own share-audio option in a browser; when audio is refused the capture continues without it); `resolution` is `1080p` (default) or `source`; `frameRate` defaults to 30; `cursor:true` needs StillMade Desktop. It resolves to `{recording, kind, microphone, mimeType, width, height, limits}`. `record.status` answers at any time with `{active, platform, kind, elapsed, bytes, ended, stoppedBy, message, limits}` so a view can show a timer and what is left.
|
|
55
|
+
|
|
52
56
|
Inside your validated `view.javascript`, attach explicit start/stop controls:
|
|
53
57
|
|
|
54
58
|
```js
|
|
@@ -72,11 +76,17 @@ stop.addEventListener('click', async () => {
|
|
|
72
76
|
});
|
|
73
77
|
```
|
|
74
78
|
|
|
75
|
-
|
|
79
|
+
Every recording in a browser, and a screen recording on desktop, begins from StillMade's own Start button outside the Block interface (labelled for the screen, camera or microphone): a view never reaches a device by itself, and the site's standing camera or microphone permission is not consent for a Block. On desktop, camera and microphone recordings begin after StillMade's consent dialog naming the Block.
|
|
80
|
+
|
|
81
|
+
`record.stop` returns `{asset, duration, width, height, bytes, mimeType, stoppedBy, message, cursor, displays, coordinates}`. `asset` is a canonical media reference with `assetId`, `versionId`, `kind` and a device-local `sm-media:` URL (`sm-media://asset/...` from the desktop media library, `sm-media://device/...` for a file a browser keeps on the device, which `previewMedia`, module `files.read` and the next Step read without uploading). `duration` is seconds measured by the host and written into the WebM header, so the recording seeks and cuts correctly. `stoppedBy` is present when the host ended the recording before `record.stop`: `size`, `time` or `sharing` (the person ended the share); the recording up to that point is kept and `message` says so. Cursor samples contain `{x,y,time}`: global device-independent screen coordinates and milliseconds relative to recording start. Multiple monitors can have negative coordinates. These are not automatically converted into cropped-window video coordinates. Share/upload media through the existing host media flow before teammates on other devices can use it. By default that flow uploads a 1080p working copy of large video (the account's media setting); the original stays in the desktop media library.
|
|
82
|
+
|
|
83
|
+
`record.cut` turns the kept ranges of a recording into a new file on the device: `StillMade.desktop('record.cut', {source, keep:[{start, end}]})`, where `source` is a video this Block recorded in this session or received as an input and `keep` lists ordered, non-overlapping ranges in seconds (up to 500). The host re-encodes the kept parts with no export decision (no watermark; the Editor's export applies the account's policy later): through StillMade's renderer (WebCodecs H.264 into MP4, 1080p or 720p) where the browser encodes H.264, otherwise by playing the kept parts through a canvas into MediaRecorder (WebM, at the recording's own speed), which works wherever recording works. The result is kept beside the original and the call resolves to `{asset, duration, width, height, bytes, mimeType, keep}`. While it runs, `record.status` reports `cutting: {progress}`. The first-party Screen Recorder Block (`stillmade.screen-recorder`) is the reference: record, cut in its editor, then `StillMade.run` with the result.
|
|
84
|
+
|
|
85
|
+
`device.info` (read-only, no consent needed, for a Block that declares a recording scope) tells a Block what fits here before anything is recorded: `{platform, streams, recorder, cut, budget:{seconds, bytes, bitsPerSecond}, limits}`. The browser's storage totals stay in the host; only the budget they leave is reported, to the megabyte. `recorder` says whether this browser can record at all, `cut` is `renderer` (WebCodecs), `playback` (real time) or `none`, `budget.seconds` is how long a 1080p recording fits (the storage quota in a browser, the time cap on desktop), and `streams` says whether desktop recordings go to disk as they are captured. Use it to say what fits and to point at StillMade Desktop when it does not; the Screen Recorder Block does.
|
|
76
86
|
|
|
77
87
|
`record.cancel` discards an active capture. `session.close` revokes all access and cancels capture. StillMade also renders a host-owned cancel/revoke control outside the Block iframe. Screen source selection happens for every recording. No preview can bypass consent or access the native bridge directly.
|
|
78
88
|
|
|
79
|
-
Current limits: one recording per Block workspace, 30 minutes
|
|
89
|
+
Current limits: one recording per Block workspace. In StillMade Desktop the recording streams to disk as it is captured (each chunk crosses to the desktop process as it arrives; the header is completed with the measured duration at the end), so the caps are 4 hours and free disk space; a desktop app without streamed recordings keeps 30 minutes and 128 MiB. In a browser the caps are 30 minutes, 2 GB and the browser's storage quota (`device.info` says how many minutes fit). Hitting a limit, the disk running low, or the person ending the share stops the recording and keeps what was captured; `record.stop` then saves it and reports `stoppedBy` (`size`, `time`, `disk` or `sharing`). `record.status` reports `limits` with `bytes: null` where only disk space applies. Output is WebM where the browser records it (H.264 or VP9 inside WebM in Chromium) and MP4 where only that is available (Safari). Camera recording is video-only; microphone recording is audio-only. Optional `{systemAudio:true}` is implemented through Windows loopback; other platforms reject it explicitly. Microphone mixing, global click/keyboard hooks, remote-control automation, arbitrary subprocesses, and folder watching are not provided by these scopes.
|
|
80
90
|
|
|
81
91
|
## Testing and installation
|
|
82
92
|
|
package/SDK.md
CHANGED
|
@@ -4010,7 +4010,7 @@ Standard and frame views receive the same `StillMade` object; members marked Fra
|
|
|
4010
4010
|
| `onSharedWorkRefresh(callback: () => void): () => void` | A saved project Step | Called when shared work changes elsewhere; read it again. |
|
|
4011
4011
|
| `previewSharedWorkMedia(contributionId: string): Promise<{url: string; kind: "image" \| "video" \| "audio"}>` | A saved project Step | A display copy of media attached to a shared work contribution. |
|
|
4012
4012
|
| `releaseSharedWorkMedia(url: string): void` | A saved project Step | Frees a URL previewSharedWorkMedia returned. |
|
|
4013
|
-
| `desktop(operation: string, args?: Record<string, unknown>): Promise<Record<string, unknown>>` | StillMade Desktop | Runs a declared desktop permission
|
|
4013
|
+
| `desktop(operation: string, args?: Record<string, unknown>): Promise<Record<string, unknown>>` | Every view for recording; StillMade Desktop for the rest | Runs a declared desktop permission after the person grants it. Recording (record.start, record.stop, record.cancel, record.status) and cutting a recording (record.cut) work in browsers and in StillMade Desktop; clipboard, media.pick, notifications, keep-awake and native tools need StillMade Desktop. |
|
|
4014
4014
|
|
|
4015
4015
|
## StillMade Blocks and the SDK
|
|
4016
4016
|
|
|
@@ -10640,16 +10640,18 @@ Offline `validate`, `test`, `pack`, `admitLocalPackage` and `admitPackage` witho
|
|
|
10640
10640
|
|
|
10641
10641
|
# Desktop capabilities for Blocks
|
|
10642
10642
|
|
|
10643
|
+
Recording runs on both platforms: in a browser the trusted web host records with the browser's own screen, camera or microphone picker and keeps the finished file on the device (a `sm-media://device/...` reference); in StillMade Desktop the native host gives consent and picks the source. Every other scope below needs StillMade Desktop.
|
|
10644
|
+
|
|
10643
10645
|
The desktop host implements these capabilities on demand. Importing a Block, installing StillMade, or opening a preview does not request device permissions. The first operation displays StillMade's native consent dialog identifying the Block ID and version. An OS prompt may follow. Grants last for the mounted Block workspace, up to one hour; closing/reloading it or revoking access ends that grant. Imported UI receives neither native IPC nor grant tokens.
|
|
10644
10646
|
|
|
10645
10647
|
Declare the exact required scopes in `manifest.permissions.desktop`. Other permission families remain unchanged. Pure recipe/JavaScript execution stays sandboxed; the **Block UI bridge** requests desktop operations through the trusted host.
|
|
10646
10648
|
|
|
10647
10649
|
| Scope | UI operation | Behavior |
|
|
10648
10650
|
| --- | --- | --- |
|
|
10649
|
-
| `screen.capture` | `record.start`, `{kind:"screen"}` | Choose a screen or window, then record it. |
|
|
10651
|
+
| `screen.capture` | `record.start`, `{kind:"screen"}` | Choose a screen or window, then record it. Browser and desktop. |
|
|
10650
10652
|
| `cursor.track` | `cursor.track`, or `{cursor:true}` on `record.start` | Global cursor coordinates and display geometry; recordings sample at 10 Hz. Does not monitor clicks or keyboard input. |
|
|
10651
|
-
| `camera.capture` | `record.start`, `{kind:"camera"}` | Camera recording after device/OS approval. |
|
|
10652
|
-
| `microphone.capture` | `record.start`, `{kind:"microphone"}` | Microphone recording after device/OS approval. |
|
|
10653
|
+
| `camera.capture` | `record.start`, `{kind:"camera"}` | Camera recording after device/OS approval. Browser and desktop. |
|
|
10654
|
+
| `microphone.capture` | `record.start`, `{kind:"microphone"}`, or `{kind:"screen", microphone:true}` | Microphone recording after device/OS approval, or the microphone mixed into a screen recording. Browser and desktop. |
|
|
10653
10655
|
| `clipboard.read` | `clipboard.read` | Returns `{text}`; bounded to 100,000 characters. |
|
|
10654
10656
|
| `clipboard.write` | `clipboard.write`, `{text}` | Explicit permission to replace clipboard text. |
|
|
10655
10657
|
| `media.pick` | `media.pick` | Native picker returns `{assets}` for up to 20 selected media files; no arbitrary path access. |
|
|
@@ -10657,7 +10659,7 @@ Declare the exact required scopes in `manifest.permissions.desktop`. Other permi
|
|
|
10657
10659
|
| `power.keep-awake` | `power.keep-awake`, `{enabled:true}` | Prevent app suspension until disabled, revoked, closed, or expired. |
|
|
10658
10660
|
| `native.tools` | `tool.status` and declared `tool.*` operations | Use one exact versioned adapter for an approved installed desktop application. |
|
|
10659
10661
|
|
|
10660
|
-
The host always checks declarations. Declaring a scope does not grant it.
|
|
10662
|
+
The host always checks declarations. Declaring a scope does not grant it. For anything but recording, a browser receives a clear desktop-required error; there is no silent paid/cloud fallback. A Block whose only desktop scopes are recording scopes is not marked as requiring StillMade Desktop.
|
|
10661
10663
|
|
|
10662
10664
|
### Approved native desktop tools
|
|
10663
10665
|
|
|
@@ -10677,7 +10679,7 @@ The mapped Natron Block uses `video` as both its primary typed input and output,
|
|
|
10677
10679
|
|
|
10678
10680
|
### Ready-to-import recording Blocks
|
|
10679
10681
|
|
|
10680
|
-
Download the complete [Screen recorder](/block-sdk/examples/screen-recorder.stillmade.json), [Camera recorder](/block-sdk/examples/camera-recorder.stillmade.json), or [Microphone recorder](/block-sdk/examples/microphone-recorder.stillmade.json). These packages also ship in the SDK archive. Import one through StillMade’s normal review flow, then use
|
|
10682
|
+
Download the complete [Screen recorder](/block-sdk/examples/screen-recorder.stillmade.json), [Camera recorder](/block-sdk/examples/camera-recorder.stillmade.json), or [Microphone recorder](/block-sdk/examples/microphone-recorder.stillmade.json). These packages also ship in the SDK archive. Import one through StillMade’s normal review flow, then use it in a browser or in the desktop app. Each includes a themed interface and pure runtime fixtures that preserve the media reference. The fixtures use synthetic references; they do not exercise your device.
|
|
10681
10683
|
|
|
10682
10684
|
The interface saves recording media before sending it through the runtime. If that handoff fails, Retry uses the saved recording without recording again.
|
|
10683
10685
|
|
|
@@ -10689,6 +10691,8 @@ Manifest fragment:
|
|
|
10689
10691
|
{"permissions":{"project":[],"network":[],"filesystem":[],"secrets":[],"desktop":["screen.capture","cursor.track"]}}
|
|
10690
10692
|
```
|
|
10691
10693
|
|
|
10694
|
+
`record.start` takes `{kind, microphone, systemAudio, resolution, frameRate, cursor}`: `kind` is `screen` (default), `camera` or `microphone`; `microphone:true` mixes the microphone into a screen recording (declare `microphone.capture` too); `systemAudio:true` asks for the shared audio (Windows loopback on desktop, the browser's own share-audio option in a browser; when audio is refused the capture continues without it); `resolution` is `1080p` (default) or `source`; `frameRate` defaults to 30; `cursor:true` needs StillMade Desktop. It resolves to `{recording, kind, microphone, mimeType, width, height, limits}`. `record.status` answers at any time with `{active, platform, kind, elapsed, bytes, ended, stoppedBy, message, limits}` so a view can show a timer and what is left.
|
|
10695
|
+
|
|
10692
10696
|
Inside your validated `view.javascript`, attach explicit start/stop controls:
|
|
10693
10697
|
|
|
10694
10698
|
```js
|
|
@@ -10712,11 +10716,17 @@ stop.addEventListener('click', async () => {
|
|
|
10712
10716
|
});
|
|
10713
10717
|
```
|
|
10714
10718
|
|
|
10715
|
-
|
|
10719
|
+
Every recording in a browser, and a screen recording on desktop, begins from StillMade's own Start button outside the Block interface (labelled for the screen, camera or microphone): a view never reaches a device by itself, and the site's standing camera or microphone permission is not consent for a Block. On desktop, camera and microphone recordings begin after StillMade's consent dialog naming the Block.
|
|
10720
|
+
|
|
10721
|
+
`record.stop` returns `{asset, duration, width, height, bytes, mimeType, stoppedBy, message, cursor, displays, coordinates}`. `asset` is a canonical media reference with `assetId`, `versionId`, `kind` and a device-local `sm-media:` URL (`sm-media://asset/...` from the desktop media library, `sm-media://device/...` for a file a browser keeps on the device, which `previewMedia`, module `files.read` and the next Step read without uploading). `duration` is seconds measured by the host and written into the WebM header, so the recording seeks and cuts correctly. `stoppedBy` is present when the host ended the recording before `record.stop`: `size`, `time` or `sharing` (the person ended the share); the recording up to that point is kept and `message` says so. Cursor samples contain `{x,y,time}`: global device-independent screen coordinates and milliseconds relative to recording start. Multiple monitors can have negative coordinates. These are not automatically converted into cropped-window video coordinates. Share/upload media through the existing host media flow before teammates on other devices can use it. By default that flow uploads a 1080p working copy of large video (the account's media setting); the original stays in the desktop media library.
|
|
10722
|
+
|
|
10723
|
+
`record.cut` turns the kept ranges of a recording into a new file on the device: `StillMade.desktop('record.cut', {source, keep:[{start, end}]})`, where `source` is a video this Block recorded in this session or received as an input and `keep` lists ordered, non-overlapping ranges in seconds (up to 500). The host re-encodes the kept parts with no export decision (no watermark; the Editor's export applies the account's policy later): through StillMade's renderer (WebCodecs H.264 into MP4, 1080p or 720p) where the browser encodes H.264, otherwise by playing the kept parts through a canvas into MediaRecorder (WebM, at the recording's own speed), which works wherever recording works. The result is kept beside the original and the call resolves to `{asset, duration, width, height, bytes, mimeType, keep}`. While it runs, `record.status` reports `cutting: {progress}`. The first-party Screen Recorder Block (`stillmade.screen-recorder`) is the reference: record, cut in its editor, then `StillMade.run` with the result.
|
|
10724
|
+
|
|
10725
|
+
`device.info` (read-only, no consent needed, for a Block that declares a recording scope) tells a Block what fits here before anything is recorded: `{platform, streams, recorder, cut, budget:{seconds, bytes, bitsPerSecond}, limits}`. The browser's storage totals stay in the host; only the budget they leave is reported, to the megabyte. `recorder` says whether this browser can record at all, `cut` is `renderer` (WebCodecs), `playback` (real time) or `none`, `budget.seconds` is how long a 1080p recording fits (the storage quota in a browser, the time cap on desktop), and `streams` says whether desktop recordings go to disk as they are captured. Use it to say what fits and to point at StillMade Desktop when it does not; the Screen Recorder Block does.
|
|
10716
10726
|
|
|
10717
10727
|
`record.cancel` discards an active capture. `session.close` revokes all access and cancels capture. StillMade also renders a host-owned cancel/revoke control outside the Block iframe. Screen source selection happens for every recording. No preview can bypass consent or access the native bridge directly.
|
|
10718
10728
|
|
|
10719
|
-
Current limits: one recording per Block workspace, 30 minutes
|
|
10729
|
+
Current limits: one recording per Block workspace. In StillMade Desktop the recording streams to disk as it is captured (each chunk crosses to the desktop process as it arrives; the header is completed with the measured duration at the end), so the caps are 4 hours and free disk space; a desktop app without streamed recordings keeps 30 minutes and 128 MiB. In a browser the caps are 30 minutes, 2 GB and the browser's storage quota (`device.info` says how many minutes fit). Hitting a limit, the disk running low, or the person ending the share stops the recording and keeps what was captured; `record.stop` then saves it and reports `stoppedBy` (`size`, `time`, `disk` or `sharing`). `record.status` reports `limits` with `bytes: null` where only disk space applies. Output is WebM where the browser records it (H.264 or VP9 inside WebM in Chromium) and MP4 where only that is available (Safari). Camera recording is video-only; microphone recording is audio-only. Optional `{systemAudio:true}` is implemented through Windows loopback; other platforms reject it explicitly. Microphone mixing, global click/keyboard hooks, remote-control automation, arbitrary subprocesses, and folder watching are not provided by these scopes.
|
|
10720
10730
|
|
|
10721
10731
|
### Testing and installation
|
|
10722
10732
|
|
|
@@ -12233,6 +12243,7 @@ The latest version of each Block bundled with the SDK, generated from the packag
|
|
|
12233
12243
|
| `stillmade.photo-reel-composer` 1.0.2 | Photo Reel Composer | task | `assets` asset[] (legacy.asset-list.assets) · primary; `purpose` text (stillmade.photo_reel.purpose); `title` text (stillmade.photo_reel.title); `captions` text (stillmade.photo_reel.captions); `cta` text (stillmade.photo_reel.cta); `contact` text (stillmade.photo_reel.contact); `demoDisclosure` text (stillmade.photo_reel.demodisclosure); `duration` number (stillmade.photo_reel.duration); `aspect` text (stillmade.photo_reel.aspect); `logo` asset[] (stillmade.photo_reel.logo); `music` asset[] (stillmade.photo_reel.music) | `timeline` timeline (legacy.timeline.updatededitor) · primary; `review` json (stillmade.photo_reel.review); `provenance` json (stillmade.photo_reel.provenance) |
|
|
12234
12244
|
| `stillmade.product-ad-variants` 1.0.0 | Product Ad Variants | task | `timeline` timeline (legacy.timeline.updatededitor) · primary; `hookA` text (stillmade.product_ad_variants.hooka); `hookB` text (stillmade.product_ad_variants.hookb); `hookC` text (stillmade.product_ad_variants.hookc); `openingA` number (stillmade.product_ad_variants.openinga); `openingB` number (stillmade.product_ad_variants.openingb); `openingC` number (stillmade.product_ad_variants.openingc); `openingDuration` number (stillmade.product_ad_variants.openingduration); `copyApproved` boolean (stillmade.product_ad_variants.copyapproved) | `variantA` timeline (legacy.timeline.updatededitor) · primary; `variantB` timeline (legacy.timeline.updatededitor); `variantC` timeline (legacy.timeline.updatededitor); `review` json (stillmade.product_ad_variants.review) |
|
|
12235
12245
|
| `stillmade.release-visuals` 1.0.2 | Release Visuals | task | `assets` asset[] (legacy.asset-list.assets) · primary; `purpose` text (stillmade.release_visuals.purpose); `title` text (stillmade.release_visuals.title); `artist` text (stillmade.release_visuals.artist); `cta` text (stillmade.release_visuals.cta); `duration` number (stillmade.release_visuals.duration); `songVolume` number (stillmade.release_visuals.songvolume); `offset` number (stillmade.release_visuals.offset); `lyrics` text (stillmade.release_visuals.lyrics); `aspect` text (stillmade.release_visuals.aspect); `disclosure` text (stillmade.release_visuals.disclosure) | `timeline` timeline (legacy.timeline.updatededitor) · primary; `review` json (stillmade.release_visuals.review) |
|
|
12246
|
+
| `stillmade.screen-recorder` 1.0.0 | Screen Recorder | task | `video` video (source_video) · primary; `original` video (source_video) | `video` video (edited_video) · primary; `original` video (source_video) |
|
|
12236
12247
|
| `stillmade.short-form-finish` 1.0.3 | Short-form Finish | task | `assets` video[] (legacy.asset-list.assets) · primary; `music` audio[] (legacy.asset-list.assets); `audioMode` text (stillmade.short_form.audiomode); `speechVolume` number (stillmade.short_form.speechvolume); `purpose` text (stillmade.short_form.purpose); `cuts` text (stillmade.short_form.cuts); `subtitles` text (stillmade.short_form.subtitles); `title` text (stillmade.short_form.title); `byline` text (stillmade.short_form.byline); `cta` text (stillmade.short_form.cta); `disclosure` text (stillmade.short_form.disclosure); `captionStyle` text (stillmade.short_form.captionstyle); `aspect` text (stillmade.short_form.aspect); `fit` text (stillmade.short_form.fit); `cropX` number (stillmade.short_form.cropx) | `timeline` timeline (legacy.timeline.updatededitor) · primary; `review` json (stillmade.short_form.review) |
|
|
12237
12248
|
|
|
12238
12249
|
## Custom view rules
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {default} from './versions/1.0.0/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.0",
|
|
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\" data-layout=\"stack\">\n <section id=\"ready\" class=\"sr-panel\">\n <p class=\"sr-lead\">Record a screen or window. The recording stays on this device until you pass it on.</p>\n <p class=\"sr-capacity\" id=\"capacity\" role=\"status\"></p>\n <div class=\"sr-options\">\n <label class=\"sr-check\"><input id=\"mic\" type=\"checkbox\" data-stillmade-share=\"microphone\"> Microphone</label>\n <label class=\"sr-check\"><input id=\"system\" type=\"checkbox\" data-stillmade-share=\"computerSound\"> Computer sound</label>\n <label class=\"sr-select\">Size <select id=\"resolution\" data-stillmade-share=\"resolution\"><option value=\"1080p\" selected>1080p</option><option value=\"source\">Full size</option></select></label>\n </div>\n <div class=\"sm-actions\">\n <button type=\"button\" class=\"sm-primary\" id=\"start\">Start recording</button>\n <p class=\"sm-status\" id=\"ready-status\" role=\"status\">Nothing is captured until you press Start in the StillMade controls above.</p>\n </div>\n </section>\n <section id=\"live\" class=\"sr-panel\" hidden>\n <div class=\"sr-live\">\n <span class=\"sr-dot\" aria-hidden=\"true\"></span>\n <strong id=\"timer\" class=\"sr-timer\">0:00.0</strong>\n <span id=\"size\" class=\"sr-size\">0 KB</span>\n </div>\n <div class=\"sm-actions\">\n <button type=\"button\" class=\"sm-primary\" id=\"stop\">Stop and edit</button>\n <button type=\"button\" class=\"sr-btn\" id=\"discard\">Discard</button>\n <p class=\"sm-status\" id=\"live-status\" role=\"status\">Recording. Press Stop and edit when you are done.</p>\n <p class=\"sr-capacity\" id=\"live-capacity\" role=\"status\"></p>\n </div>\n </section>\n <section id=\"editor\" class=\"sr-panel\" hidden>\n <video id=\"player\" class=\"sr-player\" playsinline preload=\"metadata\"></video>\n <div class=\"sr-transport\">\n <button type=\"button\" class=\"sr-btn\" id=\"play\" aria-label=\"Play\">Play</button>\n <span id=\"time\" class=\"sr-time\">0:00.0 / 0:00.0</span>\n <label class=\"sr-check sr-skip\"><input id=\"skip\" type=\"checkbox\" checked data-stillmade-local> Skip removed parts</label>\n </div>\n <div id=\"timeline\" class=\"sr-timeline\" role=\"group\" aria-label=\"Recording timeline\" tabindex=\"0\">\n <div id=\"segments\" class=\"sr-segments\"></div>\n <div id=\"playhead\" class=\"sr-playhead\" aria-hidden=\"true\"></div>\n </div>\n <div class=\"sr-tools\">\n <button type=\"button\" class=\"sr-btn\" id=\"cut\">Cut here</button>\n <button type=\"button\" class=\"sr-btn\" id=\"toggle\" disabled>Remove part</button>\n <button type=\"button\" class=\"sr-btn\" id=\"undo\" disabled>Undo</button>\n <span id=\"kept\" class=\"sr-kept\"></span>\n </div>\n <div class=\"sm-actions\">\n <button type=\"button\" class=\"sm-primary\" id=\"save\">Use the whole recording</button>\n <button type=\"button\" class=\"sr-btn\" id=\"again\">Record again</button>\n <p class=\"sm-status\" id=\"editor-status\" role=\"status\"></p>\n </div>\n </section>\n <section id=\"done\" class=\"sr-panel\" hidden>\n <p class=\"sr-lead\" id=\"done-text\">Saved.</p>\n <div class=\"sm-actions\">\n <button type=\"button\" class=\"sm-primary\" id=\"edit-again\">Edit again</button>\n <button type=\"button\" class=\"sr-btn\" id=\"record-again\">Record again</button>\n </div>\n </section>\n</main>\n",
|
|
97
|
+
"css": ".sr { display: block; }\n.sr-panel { display: grid; gap: 1rem; padding: 1.25rem; }\n.sr-lead { margin: 0; color: var(--fg-muted); }\n.sr-capacity { margin: 0; color: var(--fg-muted); font-size: .92em; }\n.sr-capacity[data-warn=\"true\"] { color: var(--warning); }\n.sr-options { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }\n.sr-check { display: inline-flex; align-items: center; gap: .45rem; color: var(--fg); }\n.sr-select { display: inline-flex; align-items: center; gap: .45rem; color: var(--fg); }\n.sr-select select { border: 1px solid var(--border-tok); border-radius: var(--r-3); background: var(--bg-elev-1); color: var(--fg); padding: .35rem .5rem; }\n.sr-btn { border: 1px solid var(--border-tok); border-radius: 999px; padding: .6rem 1rem; background: var(--bg-elev-1); color: var(--fg); cursor: pointer; }\n.sr-btn:hover { background: var(--hover-wash); }\n.sr-btn:disabled, .sm-primary:disabled { opacity: .5; cursor: default; }\n.sr-live { display: flex; align-items: center; gap: .75rem; }\n.sr-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--danger); }\n.sr-timer { font-size: 1.6rem; font-variant-numeric: tabular-nums; }\n.sr-size { color: var(--fg-muted); font-variant-numeric: tabular-nums; }\n.sr-player { width: 100%; max-height: 52vh; background: var(--bg-inset); border-radius: var(--r-3); }\n.sr-transport { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }\n.sr-time { font-variant-numeric: tabular-nums; color: var(--fg-muted); }\n.sr-skip { margin-left: auto; color: var(--fg-muted); }\n.sr-timeline { position: relative; height: 56px; border-radius: var(--r-3); background: var(--bg-inset); border: 1px solid var(--border-tok); overflow: hidden; touch-action: none; user-select: none; }\n.sr-timeline:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n.sr-segments { position: absolute; inset: 0; }\n.sr-seg { position: absolute; top: 0; bottom: 0; border: 0; padding: 0; background: var(--accent-dim); cursor: pointer; }\n.sr-seg[data-kept=\"false\"] { background: var(--hover-wash); opacity: .55; }\n.sr-seg[aria-pressed=\"true\"] { box-shadow: inset 0 0 0 2px var(--accent); opacity: 1; }\n.sr-handle { position: absolute; top: 0; bottom: 0; width: 16px; margin-left: -8px; cursor: ew-resize; background: transparent; border: 0; padding: 0; }\n.sr-handle::after { content: \"\"; position: absolute; top: 6px; bottom: 6px; left: 6px; width: 4px; border-radius: 2px; background: var(--fg); opacity: .7; }\n.sr-handle:hover::after, .sr-handle[data-active=\"true\"]::after { background: var(--accent); opacity: 1; }\n.sr-playhead { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--fg); pointer-events: none; }\n.sr-tools { display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; }\n.sr-kept { margin-left: auto; color: var(--fg-muted); font-variant-numeric: tabular-nums; }\n@media (max-width: 480px) { .sr-panel { padding: 1rem; } .sr-skip { margin-left: 0; } }\n",
|
|
98
|
+
"javascript": "(()=>{\n\"use strict\";\n// The cut editor's model: the recording is an ordered list of segments that\n// together cover it, each kept or removed. A cut splits the segment under the\n// playhead, removing a segment drops it from the result, and dragging a\n// boundary trims both neighbours. Every change returns a new array, so undo\n// keeps the earlier ones. Times are seconds of the source recording.\nconst MIN_SEGMENT = 0.1;\nlet nextSegmentId = 1;\nfunction createSegments(duration) {\n nextSegmentId = 1;\n return [{ id: nextSegmentId++, start: 0, end: duration, kept: true }];\n}\nfunction segmentAt(segments, time) {\n const found = segments.find(segment => time >= segment.start && time < segment.end);\n if (found) return found;\n const last = segments[segments.length - 1];\n return last && time >= last.end ? last : null;\n}\nfunction splitAt(segments, time) {\n const index = segments.findIndex(segment => time > segment.start + MIN_SEGMENT && time < segment.end - MIN_SEGMENT);\n if (index < 0) return segments;\n const segment = segments[index];\n return [...segments.slice(0, index), { ...segment, end: time }, { id: nextSegmentId++, start: time, end: segment.end, kept: segment.kept }, ...segments.slice(index + 1)];\n}\nfunction toggleKept(segments, id) {\n const index = segments.findIndex(segment => segment.id === id);\n if (index < 0) return segments;\n const next = segments.slice();\n next[index] = { ...next[index], kept: !next[index].kept };\n return next;\n}\n/** Moves the boundary before segments[index], clamped so both neighbours keep a minimum length. */\nfunction moveBoundary(segments, index, time) {\n if (index < 1 || index >= segments.length) return segments;\n const left = segments[index - 1], right = segments[index];\n const clamped = Math.min(right.end - MIN_SEGMENT, Math.max(left.start + MIN_SEGMENT, time));\n if (clamped === left.end) return segments;\n const next = segments.slice();\n next[index - 1] = { ...left, end: clamped };\n next[index] = { ...right, start: clamped };\n return next;\n}\n/** Trims the recording's start or end to `time`: the trimmed part becomes a removed segment. */\nfunction trimEdge(segments, edge, time) {\n if (edge === 'start') {\n if (!segments[0].kept) return moveBoundary(segments, 1, time);\n const split = splitAt(segments, time);\n return split === segments ? segments : toggleKept(split, split[0].id);\n }\n const last = segments[segments.length - 1];\n if (!last.kept) return moveBoundary(segments, segments.length - 1, time);\n const split = splitAt(segments, time);\n return split === segments ? segments : toggleKept(split, split[split.length - 1].id);\n}\n/** The kept ranges in order, neighbours merged: what record.cut keeps. */\nfunction keepRanges(segments) {\n const ranges = [];\n for (const segment of segments) {\n if (!segment.kept) continue;\n const last = ranges[ranges.length - 1];\n if (last && Math.abs(last.end - segment.start) < 1e-6) last.end = segment.end;\n else ranges.push({ start: segment.start, end: segment.end });\n }\n return ranges;\n}\nfunction keptDuration(segments) {\n return segments.reduce((sum, segment) => segment.kept ? sum + segment.end - segment.start : sum, 0);\n}\nfunction isWhole(segments) {\n return segments.every(segment => segment.kept);\n}\n/** Where playback continues from `time` when removed parts are skipped: `time` itself when kept, the next kept start, or null when nothing kept follows. */\nfunction nextKeptTime(segments, time) {\n const segment = segmentAt(segments, time);\n if (!segment || segment.kept) return time;\n const later = segments.find(candidate => candidate.kept && candidate.start >= segment.end);\n return later ? later.start : null;\n}\nfunction firstKeptTime(segments) {\n const segment = segments.find(candidate => candidate.kept);\n return segment ? segment.start : 0;\n}\n/** Seconds as m:ss.d */\nfunction formatTime(seconds) {\n const total = Math.max(0, Math.round((Number(seconds) || 0) * 10) / 10);\n const minutes = Math.floor(total / 60), whole = Math.floor(total % 60), tenth = Math.round((total - Math.floor(total)) * 10) % 10;\n return `${minutes}:${String(whole).padStart(2, '0')}.${tenth}`;\n}\nfunction formatBytes(bytes) {\n const value = Number(bytes) || 0;\n return value >= 1024 * 1024 * 1024 ? `${(value / 1024 / 1024 / 1024).toFixed(2)} GB` : value >= 1024 * 1024 ? `${Math.round(value / 1024 / 1024)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;\n}\n\n// The Screen Recorder interface: record (the host records, this view only asks),\n// then a small cut editor over the recording kept on this device, then one\n// run that hands the result on. Built into src/view.js with the cut model.\nconst $ = id => document.getElementById(id);\nconst panels = { ready: $('ready'), live: $('live'), editor: $('editor'), done: $('done') };\nconst player = $('player'), timeline = $('timeline'), segmentsNode = $('segments'), playhead = $('playhead');\nconst state = { phase: 'ready', source: null, segments: [], undo: [], selected: null, duration: 0, previewUrl: null, dragging: null, poll: null, busy: false, lastSaved: null, device: null };\n\nfunction show(phase) {\n state.phase = phase;\n for (const [name, node] of Object.entries(panels)) node.hidden = name !== phase;\n}\nconst say = (id, text) => { $(id).textContent = text || ''; };\nconst message = error => String(error && error.message || error || 'Something went wrong.');\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\nconst busy = value => { state.busy = value; for (const id of ['start', 'stop', 'discard', 'cut', 'toggle', 'undo', 'save', 'again', 'edit-again', 'record-again']) $(id).disabled = value || ($(id).dataset.off === 'true'); };\n\n// ── What fits here ─────────────────────────────────────────────────────────\nconst minutes = seconds => Math.max(1, Math.round(seconds / 60));\nfunction describeDevice(device) {\n if (!device) return '';\n if (!device.recorder) return 'This browser cannot record. Use a current desktop browser or StillMade Desktop.';\n const notes = [];\n if (device.platform === 'desktop') notes.push('Records straight to disk; up to ' + minutes(device.budget.seconds) + ' minutes per recording.');\n else if (device.budget.seconds < 60) notes.push('Less than a minute fits on this device. Free up storage or use StillMade Desktop.');\n else notes.push('About ' + minutes(device.budget.seconds) + ' minutes at 1080p fit on this device' + (device.budget.bytes ? ' (' + formatBytes(device.budget.bytes) + ')' : '') + '. StillMade Desktop records longer.');\n if (device.cut === 'playback') notes.push('Cuts here take as long as the part you keep.');\n if (device.cut === 'none') notes.push('This browser cannot cut recordings; the whole recording can still be used, or cut it in StillMade Desktop.');\n return notes.join(' ');\n}\nasync function loadDevice() {\n let problem = null;\n try { state.device = await StillMade.desktop('device.info'); } catch (error) { state.device = null; problem = message(error); }\n say('capacity', state.device ? describeDevice(state.device) : `Could not check what fits on this device: ${problem}`);\n $('capacity').dataset.warn = String(!!problem || (!!state.device && (!state.device.recorder || state.device.budget.seconds < 60)));\n}\nfunction describeLive(status) {\n const limits = status.limits || {};\n const parts = [];\n let warn = false;\n if (limits.seconds) {\n const left = Math.max(0, limits.seconds - (status.elapsed || 0) / 1000);\n if (left <= limits.seconds * 0.2) { warn = true; parts.push(formatTime(left) + ' left before the time limit.'); }\n }\n if (limits.bytes) {\n const left = Math.max(0, limits.bytes - (status.bytes || 0));\n if (left <= limits.bytes * 0.2) { warn = true; parts.push(formatBytes(left) + ' of space left here. The recording stops and is kept when it runs out.'); }\n }\n return { text: parts.join(' '), warn };\n}\n\n// ── Record ─────────────────────────────────────────────────────────────────\nasync function pollStatus() {\n if (state.phase !== 'live') return;\n try {\n const status = await StillMade.desktop('record.status');\n if (state.phase !== 'live') return;\n if (status.active) {\n say('timer', formatTime((status.elapsed || 0) / 1000));\n say('size', formatBytes(status.bytes));\n const live = describeLive(status);\n say('live-capacity', status.ended ? '' : live.text); $('live-capacity').dataset.warn = String(live.warn && !status.ended);\n if (status.ended) say('live-status', `${status.message || 'The recording stopped.'} Press Stop and edit to keep it.`);\n }\n } catch (error) { say('live-status', message(error)); }\n state.poll = setTimeout(pollStatus, 1000);\n}\n$('start').addEventListener('click', async () => {\n busy(true);\n say('ready-status', 'Press Start in the StillMade controls above, then choose what to record.');\n try {\n await StillMade.desktop('record.start', { kind: 'screen', microphone: $('mic').checked, systemAudio: $('system').checked, resolution: $('resolution').value });\n say('timer', '0:00.0'); say('size', '0 KB'); say('live-status', 'Recording. Press Stop and edit when you are done.'); say('live-capacity', '');\n show('live'); pollStatus();\n } catch (error) { say('ready-status', message(error)); }\n finally { busy(false); }\n});\n$('stop').addEventListener('click', async () => {\n busy(true); clearTimeout(state.poll);\n say('live-status', 'Saving the recording on this device…');\n try {\n const result = await StillMade.desktop('record.stop');\n const asset = { ...result.asset };\n for (const key of ['duration', 'width', 'height']) if (result[key] && !asset[key]) asset[key] = result[key];\n await openEditor(asset);\n say('editor-status', result.stoppedBy ? result.message : '');\n } catch (error) { say('live-status', message(error)); state.phase = 'live'; pollStatus(); }\n finally { busy(false); }\n});\n$('discard').addEventListener('click', async () => {\n busy(true); clearTimeout(state.poll);\n try { await StillMade.desktop('record.cancel'); } catch (error) { /* already gone */ }\n say('ready-status', 'Discarded. Ready to record again.'); show('ready'); busy(false);\n});\nconst recordAgain = () => { closePreview(); state.source = null; say('ready-status', 'Ready to record.'); show('ready'); loadDevice(); };\n$('again').addEventListener('click', recordAgain);\n$('record-again').addEventListener('click', recordAgain);\n\n// ── Editor ─────────────────────────────────────────────────────────────────\nfunction closePreview() {\n player.pause();\n if (state.previewUrl) { try { StillMade.releaseMedia(state.previewUrl); } catch (error) { /* released */ } state.previewUrl = null; }\n player.removeAttribute('src'); player.load();\n}\nasync function openEditor(source) {\n closePreview();\n state.source = source; state.selected = null; state.undo = [];\n const { url } = await StillMade.previewMedia(source);\n state.previewUrl = url;\n const duration = await new Promise((resolve, reject) => {\n const done = () => { player.onloadedmetadata = null; player.onerror = null; resolve(Number.isFinite(player.duration) && player.duration > 0 ? player.duration : Number(source.duration) || 0); };\n player.onloadedmetadata = done;\n player.onerror = () => reject(new Error('This recording could not be opened for editing.'));\n player.src = url;\n });\n if (!(duration > 0)) throw new Error('The recording has no length to edit.');\n state.duration = duration;\n state.segments = createSegments(duration);\n renderTimeline(); updateTime(); updateButtons();\n say('editor-status', '');\n show('editor');\n}\nconst fraction = time => Math.max(0, Math.min(1, time / state.duration));\nfunction renderTimeline() {\n segmentsNode.replaceChildren();\n state.segments.forEach((segment, index) => {\n const node = document.createElement('button');\n node.type = 'button'; node.className = 'sr-seg'; node.dataset.id = String(segment.id); node.dataset.kept = String(segment.kept);\n node.setAttribute('aria-pressed', String(state.selected === segment.id));\n node.setAttribute('aria-label', `${segment.kept ? 'Kept' : 'Removed'} part from ${formatTime(segment.start)} to ${formatTime(segment.end)}`);\n node.style.left = `${fraction(segment.start) * 100}%`; node.style.width = `${(fraction(segment.end) - fraction(segment.start)) * 100}%`;\n node.addEventListener('click', event => { state.selected = state.selected === segment.id ? null : segment.id; seekTo(timeAt(event.clientX)); renderTimeline(); updateButtons(); });\n segmentsNode.append(node);\n if (index > 0) {\n const handle = document.createElement('button');\n handle.type = 'button'; handle.className = 'sr-handle'; handle.dataset.index = String(index);\n handle.setAttribute('aria-label', `Move the cut at ${formatTime(segment.start)}`);\n handle.style.left = `${fraction(segment.start) * 100}%`;\n handle.addEventListener('pointerdown', beginDrag);\n segmentsNode.append(handle);\n }\n });\n say('kept', isWhole(state.segments) ? `${formatTime(state.duration)} kept` : `${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)} kept`);\n}\nfunction timeAt(clientX) {\n const box = timeline.getBoundingClientRect();\n return box.width > 0 ? Math.max(0, Math.min(state.duration, (clientX - box.left) / box.width * state.duration)) : 0;\n}\nfunction beginDrag(event) {\n event.preventDefault(); event.stopPropagation();\n const index = Number(event.currentTarget.dataset.index);\n state.dragging = { index, before: state.segments, handle: event.currentTarget, pointerId: event.pointerId };\n event.currentTarget.setPointerCapture(event.pointerId);\n event.currentTarget.dataset.active = 'true';\n event.currentTarget.addEventListener('pointermove', continueDrag);\n event.currentTarget.addEventListener('pointerup', endDrag);\n event.currentTarget.addEventListener('pointercancel', endDrag);\n}\nfunction continueDrag(event) {\n const drag = state.dragging; if (!drag) return;\n const next = moveBoundary(state.segments, drag.index, timeAt(event.clientX));\n if (next === state.segments) return;\n state.segments = next;\n const boundary = next[drag.index].start;\n drag.handle.style.left = `${fraction(boundary) * 100}%`;\n const left = segmentsNode.querySelector(`.sr-seg[data-id=\"${next[drag.index - 1].id}\"]`), right = segmentsNode.querySelector(`.sr-seg[data-id=\"${next[drag.index].id}\"]`);\n if (left) left.style.width = `${(fraction(boundary) - fraction(next[drag.index - 1].start)) * 100}%`;\n if (right) { right.style.left = `${fraction(boundary) * 100}%`; right.style.width = `${(fraction(next[drag.index].end) - fraction(boundary)) * 100}%`; }\n seekTo(boundary, { quiet: true });\n}\nfunction endDrag(event) {\n const drag = state.dragging; if (!drag) return;\n state.dragging = null;\n drag.handle.dataset.active = 'false';\n drag.handle.removeEventListener('pointermove', continueDrag);\n drag.handle.removeEventListener('pointerup', endDrag);\n drag.handle.removeEventListener('pointercancel', endDrag);\n try { drag.handle.releasePointerCapture(drag.pointerId); } catch (error) { /* released */ }\n if (drag.before !== state.segments) { state.undo.push(drag.before); if (state.undo.length > 100) state.undo.shift(); }\n renderTimeline(); updateButtons();\n}\ntimeline.addEventListener('pointerdown', event => { if (event.target === timeline || event.target === segmentsNode) seekTo(timeAt(event.clientX)); });\nfunction seekTo(time, options) {\n player.currentTime = Math.max(0, Math.min(state.duration, time));\n if (!(options && options.quiet)) updateTime();\n}\nfunction updateTime() {\n const time = player.currentTime || 0;\n playhead.style.transform = `translateX(${fraction(time) * timeline.clientWidth}px)`;\n say('time', `${formatTime(time)} / ${formatTime(state.duration)}`);\n}\nplayer.addEventListener('timeupdate', () => {\n if (state.phase !== 'editor') return;\n if ($('skip').checked && !player.paused) {\n const next = nextKeptTime(state.segments, player.currentTime);\n if (next === null) { player.pause(); }\n else if (next !== player.currentTime) { player.currentTime = next; }\n }\n updateTime();\n});\nplayer.addEventListener('play', () => { if ($('skip').checked) { const next = nextKeptTime(state.segments, player.currentTime); if (next === null) player.currentTime = firstKeptTime(state.segments); else if (next !== player.currentTime) player.currentTime = next; } $('play').textContent = 'Pause'; });\nplayer.addEventListener('pause', () => { $('play').textContent = 'Play'; });\nplayer.addEventListener('ended', () => { $('play').textContent = 'Play'; });\n$('play').addEventListener('click', () => { if (player.paused) player.play().catch(error => say('editor-status', message(error))); else player.pause(); });\nfunction apply(next) {\n if (next === state.segments) return;\n state.undo.push(state.segments); if (state.undo.length > 100) state.undo.shift();\n state.segments = next;\n renderTimeline(); updateButtons();\n}\n$('cut').addEventListener('click', () => { apply(splitAt(state.segments, player.currentTime)); });\n$('toggle').addEventListener('click', () => { if (state.selected !== null) apply(toggleKept(state.segments, state.selected)); });\n$('undo').addEventListener('click', () => { const previous = state.undo.pop(); if (previous) { state.segments = previous; if (!previous.some(segment => segment.id === state.selected)) state.selected = null; renderTimeline(); updateButtons(); } });\ntimeline.addEventListener('keydown', event => {\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); seekTo((player.currentTime || 0) + (event.key === 'ArrowLeft' ? -0.5 : 0.5) * (event.shiftKey ? 10 : 1)); }\n else if (event.key === 'c' || event.key === 'C') { event.preventDefault(); $('cut').click(); }\n else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); $('toggle').click(); }\n else if (event.key === ' ') { event.preventDefault(); $('play').click(); }\n});\nfunction updateButtons() {\n const selected = state.segments.find(segment => segment.id === state.selected);\n $('toggle').disabled = !selected; $('toggle').dataset.off = String(!selected);\n $('toggle').textContent = selected && !selected.kept ? 'Keep part' : 'Remove part';\n $('undo').disabled = !state.undo.length; $('undo').dataset.off = String(!state.undo.length);\n const whole = isWhole(state.segments), nothing = keepRanges(state.segments).length === 0;\n $('save').textContent = whole ? 'Use the whole recording' : 'Save the cut';\n $('save').disabled = nothing; $('save').dataset.off = String(nothing);\n}\nwindow.addEventListener('resize', () => { if (state.phase === 'editor') updateTime(); });\n\n// ── Save ───────────────────────────────────────────────────────────────────\n$('save').addEventListener('click', async () => {\n const source = state.source; if (!source) return;\n const keep = keepRanges(state.segments);\n if (!keep.length) { say('editor-status', 'Keep at least one part.'); return; }\n busy(true); player.pause();\n try {\n let video = source;\n if (!isWhole(state.segments)) {\n say('editor-status', 'Cutting on this device…');\n let watching = true;\n const watch = async () => { while (watching) { await sleep(700); if (!watching) break; try { const status = await StillMade.desktop('record.status'); if (status.cutting) say('editor-status', `Cutting on this device… ${Math.round(status.cutting.progress * 100)}%`); } catch (error) { /* next tick */ } } };\n watch();\n try { video = (await StillMade.desktop('record.cut', { source, keep })).asset; }\n finally { watching = false; }\n }\n say('editor-status', 'Passing the result on…');\n await StillMade.run({ video, original: source });\n state.lastSaved = video;\n say('done-text', isWhole(state.segments) ? `The whole recording (${formatTime(state.duration)}) is the result.` : `Saved ${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)}. The uncut recording is kept beside it.`);\n show('done');\n } catch (error) { say('editor-status', message(error)); }\n finally { busy(false); }\n});\n$('edit-again').addEventListener('click', () => { if (state.source) { show('editor'); say('editor-status', ''); } });\n\n// ── Input: a video from an earlier Step opens straight in the editor ──────\n// onInput first fires once the view is ready, which is also the earliest moment a host call can be made.\nStillMade.onInput(async input => {\n if (!state.device && !state.deviceAsked) { state.deviceAsked = true; loadDevice(); }\n const video = input && input.video;\n if (!video || state.source || state.phase !== 'ready') return;\n busy(true);\n try { await openEditor(video); say('editor-status', 'Editing the video this Block received. Record again to replace it.'); }\n catch (error) { say('ready-status', message(error)); }\n finally { busy(false); }\n});\n\n})();\n"
|
|
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\\\" data-layout=\\\"stack\\\">\\n <section id=\\\"ready\\\" class=\\\"sr-panel\\\">\\n <p class=\\\"sr-lead\\\">Record a screen or window. The recording stays on this device until you pass it on.</p>\\n <p class=\\\"sr-capacity\\\" id=\\\"capacity\\\" role=\\\"status\\\"></p>\\n <div class=\\\"sr-options\\\">\\n <label class=\\\"sr-check\\\"><input id=\\\"mic\\\" type=\\\"checkbox\\\" data-stillmade-share=\\\"microphone\\\"> Microphone</label>\\n <label class=\\\"sr-check\\\"><input id=\\\"system\\\" type=\\\"checkbox\\\" data-stillmade-share=\\\"computerSound\\\"> Computer sound</label>\\n <label class=\\\"sr-select\\\">Size <select id=\\\"resolution\\\" data-stillmade-share=\\\"resolution\\\"><option value=\\\"1080p\\\" selected>1080p</option><option value=\\\"source\\\">Full size</option></select></label>\\n </div>\\n <div class=\\\"sm-actions\\\">\\n <button type=\\\"button\\\" class=\\\"sm-primary\\\" id=\\\"start\\\">Start recording</button>\\n <p class=\\\"sm-status\\\" id=\\\"ready-status\\\" role=\\\"status\\\">Nothing is captured until you press Start in the StillMade controls above.</p>\\n </div>\\n </section>\\n <section id=\\\"live\\\" class=\\\"sr-panel\\\" hidden>\\n <div class=\\\"sr-live\\\">\\n <span class=\\\"sr-dot\\\" aria-hidden=\\\"true\\\"></span>\\n <strong id=\\\"timer\\\" class=\\\"sr-timer\\\">0:00.0</strong>\\n <span id=\\\"size\\\" class=\\\"sr-size\\\">0 KB</span>\\n </div>\\n <div class=\\\"sm-actions\\\">\\n <button type=\\\"button\\\" class=\\\"sm-primary\\\" id=\\\"stop\\\">Stop and edit</button>\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"discard\\\">Discard</button>\\n <p class=\\\"sm-status\\\" id=\\\"live-status\\\" role=\\\"status\\\">Recording. Press Stop and edit when you are done.</p>\\n <p class=\\\"sr-capacity\\\" id=\\\"live-capacity\\\" role=\\\"status\\\"></p>\\n </div>\\n </section>\\n <section id=\\\"editor\\\" class=\\\"sr-panel\\\" hidden>\\n <video id=\\\"player\\\" class=\\\"sr-player\\\" playsinline preload=\\\"metadata\\\"></video>\\n <div class=\\\"sr-transport\\\">\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"play\\\" aria-label=\\\"Play\\\">Play</button>\\n <span id=\\\"time\\\" class=\\\"sr-time\\\">0:00.0 / 0:00.0</span>\\n <label class=\\\"sr-check sr-skip\\\"><input id=\\\"skip\\\" type=\\\"checkbox\\\" checked data-stillmade-local> Skip removed parts</label>\\n </div>\\n <div id=\\\"timeline\\\" class=\\\"sr-timeline\\\" role=\\\"group\\\" aria-label=\\\"Recording timeline\\\" tabindex=\\\"0\\\">\\n <div id=\\\"segments\\\" class=\\\"sr-segments\\\"></div>\\n <div id=\\\"playhead\\\" class=\\\"sr-playhead\\\" aria-hidden=\\\"true\\\"></div>\\n </div>\\n <div class=\\\"sr-tools\\\">\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"cut\\\">Cut here</button>\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"toggle\\\" disabled>Remove part</button>\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"undo\\\" disabled>Undo</button>\\n <span id=\\\"kept\\\" class=\\\"sr-kept\\\"></span>\\n </div>\\n <div class=\\\"sm-actions\\\">\\n <button type=\\\"button\\\" class=\\\"sm-primary\\\" id=\\\"save\\\">Use the whole recording</button>\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"again\\\">Record again</button>\\n <p class=\\\"sm-status\\\" id=\\\"editor-status\\\" role=\\\"status\\\"></p>\\n </div>\\n </section>\\n <section id=\\\"done\\\" class=\\\"sr-panel\\\" hidden>\\n <p class=\\\"sr-lead\\\" id=\\\"done-text\\\">Saved.</p>\\n <div class=\\\"sm-actions\\\">\\n <button type=\\\"button\\\" class=\\\"sm-primary\\\" id=\\\"edit-again\\\">Edit again</button>\\n <button type=\\\"button\\\" class=\\\"sr-btn\\\" id=\\\"record-again\\\">Record again</button>\\n </div>\\n </section>\\n</main>\\n\",\n \"css\": \".sr { display: block; }\\n.sr-panel { display: grid; gap: 1rem; padding: 1.25rem; }\\n.sr-lead { margin: 0; color: var(--fg-muted); }\\n.sr-capacity { margin: 0; color: var(--fg-muted); font-size: .92em; }\\n.sr-capacity[data-warn=\\\"true\\\"] { color: var(--warning); }\\n.sr-options { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }\\n.sr-check { display: inline-flex; align-items: center; gap: .45rem; color: var(--fg); }\\n.sr-select { display: inline-flex; align-items: center; gap: .45rem; color: var(--fg); }\\n.sr-select select { border: 1px solid var(--border-tok); border-radius: var(--r-3); background: var(--bg-elev-1); color: var(--fg); padding: .35rem .5rem; }\\n.sr-btn { border: 1px solid var(--border-tok); border-radius: 999px; padding: .6rem 1rem; background: var(--bg-elev-1); color: var(--fg); cursor: pointer; }\\n.sr-btn:hover { background: var(--hover-wash); }\\n.sr-btn:disabled, .sm-primary:disabled { opacity: .5; cursor: default; }\\n.sr-live { display: flex; align-items: center; gap: .75rem; }\\n.sr-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--danger); }\\n.sr-timer { font-size: 1.6rem; font-variant-numeric: tabular-nums; }\\n.sr-size { color: var(--fg-muted); font-variant-numeric: tabular-nums; }\\n.sr-player { width: 100%; max-height: 52vh; background: var(--bg-inset); border-radius: var(--r-3); }\\n.sr-transport { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }\\n.sr-time { font-variant-numeric: tabular-nums; color: var(--fg-muted); }\\n.sr-skip { margin-left: auto; color: var(--fg-muted); }\\n.sr-timeline { position: relative; height: 56px; border-radius: var(--r-3); background: var(--bg-inset); border: 1px solid var(--border-tok); overflow: hidden; touch-action: none; user-select: none; }\\n.sr-timeline:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\\n.sr-segments { position: absolute; inset: 0; }\\n.sr-seg { position: absolute; top: 0; bottom: 0; border: 0; padding: 0; background: var(--accent-dim); cursor: pointer; }\\n.sr-seg[data-kept=\\\"false\\\"] { background: var(--hover-wash); opacity: .55; }\\n.sr-seg[aria-pressed=\\\"true\\\"] { box-shadow: inset 0 0 0 2px var(--accent); opacity: 1; }\\n.sr-handle { position: absolute; top: 0; bottom: 0; width: 16px; margin-left: -8px; cursor: ew-resize; background: transparent; border: 0; padding: 0; }\\n.sr-handle::after { content: \\\"\\\"; position: absolute; top: 6px; bottom: 6px; left: 6px; width: 4px; border-radius: 2px; background: var(--fg); opacity: .7; }\\n.sr-handle:hover::after, .sr-handle[data-active=\\\"true\\\"]::after { background: var(--accent); opacity: 1; }\\n.sr-playhead { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--fg); pointer-events: none; }\\n.sr-tools { display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; }\\n.sr-kept { margin-left: auto; color: var(--fg-muted); font-variant-numeric: tabular-nums; }\\n@media (max-width: 480px) { .sr-panel { padding: 1rem; } .sr-skip { margin-left: 0; } }\\n\",\n \"javascript\": \"(()=>{\\n\\\"use strict\\\";\\n// The cut editor's model: the recording is an ordered list of segments that\\n// together cover it, each kept or removed. A cut splits the segment under the\\n// playhead, removing a segment drops it from the result, and dragging a\\n// boundary trims both neighbours. Every change returns a new array, so undo\\n// keeps the earlier ones. Times are seconds of the source recording.\\nconst MIN_SEGMENT = 0.1;\\nlet nextSegmentId = 1;\\nfunction createSegments(duration) {\\n nextSegmentId = 1;\\n return [{ id: nextSegmentId++, start: 0, end: duration, kept: true }];\\n}\\nfunction segmentAt(segments, time) {\\n const found = segments.find(segment => time >= segment.start && time < segment.end);\\n if (found) return found;\\n const last = segments[segments.length - 1];\\n return last && time >= last.end ? last : null;\\n}\\nfunction splitAt(segments, time) {\\n const index = segments.findIndex(segment => time > segment.start + MIN_SEGMENT && time < segment.end - MIN_SEGMENT);\\n if (index < 0) return segments;\\n const segment = segments[index];\\n return [...segments.slice(0, index), { ...segment, end: time }, { id: nextSegmentId++, start: time, end: segment.end, kept: segment.kept }, ...segments.slice(index + 1)];\\n}\\nfunction toggleKept(segments, id) {\\n const index = segments.findIndex(segment => segment.id === id);\\n if (index < 0) return segments;\\n const next = segments.slice();\\n next[index] = { ...next[index], kept: !next[index].kept };\\n return next;\\n}\\n/** Moves the boundary before segments[index], clamped so both neighbours keep a minimum length. */\\nfunction moveBoundary(segments, index, time) {\\n if (index < 1 || index >= segments.length) return segments;\\n const left = segments[index - 1], right = segments[index];\\n const clamped = Math.min(right.end - MIN_SEGMENT, Math.max(left.start + MIN_SEGMENT, time));\\n if (clamped === left.end) return segments;\\n const next = segments.slice();\\n next[index - 1] = { ...left, end: clamped };\\n next[index] = { ...right, start: clamped };\\n return next;\\n}\\n/** Trims the recording's start or end to `time`: the trimmed part becomes a removed segment. */\\nfunction trimEdge(segments, edge, time) {\\n if (edge === 'start') {\\n if (!segments[0].kept) return moveBoundary(segments, 1, time);\\n const split = splitAt(segments, time);\\n return split === segments ? segments : toggleKept(split, split[0].id);\\n }\\n const last = segments[segments.length - 1];\\n if (!last.kept) return moveBoundary(segments, segments.length - 1, time);\\n const split = splitAt(segments, time);\\n return split === segments ? segments : toggleKept(split, split[split.length - 1].id);\\n}\\n/** The kept ranges in order, neighbours merged: what record.cut keeps. */\\nfunction keepRanges(segments) {\\n const ranges = [];\\n for (const segment of segments) {\\n if (!segment.kept) continue;\\n const last = ranges[ranges.length - 1];\\n if (last && Math.abs(last.end - segment.start) < 1e-6) last.end = segment.end;\\n else ranges.push({ start: segment.start, end: segment.end });\\n }\\n return ranges;\\n}\\nfunction keptDuration(segments) {\\n return segments.reduce((sum, segment) => segment.kept ? sum + segment.end - segment.start : sum, 0);\\n}\\nfunction isWhole(segments) {\\n return segments.every(segment => segment.kept);\\n}\\n/** Where playback continues from `time` when removed parts are skipped: `time` itself when kept, the next kept start, or null when nothing kept follows. */\\nfunction nextKeptTime(segments, time) {\\n const segment = segmentAt(segments, time);\\n if (!segment || segment.kept) return time;\\n const later = segments.find(candidate => candidate.kept && candidate.start >= segment.end);\\n return later ? later.start : null;\\n}\\nfunction firstKeptTime(segments) {\\n const segment = segments.find(candidate => candidate.kept);\\n return segment ? segment.start : 0;\\n}\\n/** Seconds as m:ss.d */\\nfunction formatTime(seconds) {\\n const total = Math.max(0, Math.round((Number(seconds) || 0) * 10) / 10);\\n const minutes = Math.floor(total / 60), whole = Math.floor(total % 60), tenth = Math.round((total - Math.floor(total)) * 10) % 10;\\n return `${minutes}:${String(whole).padStart(2, '0')}.${tenth}`;\\n}\\nfunction formatBytes(bytes) {\\n const value = Number(bytes) || 0;\\n return value >= 1024 * 1024 * 1024 ? `${(value / 1024 / 1024 / 1024).toFixed(2)} GB` : value >= 1024 * 1024 ? `${Math.round(value / 1024 / 1024)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;\\n}\\n\\n// The Screen Recorder interface: record (the host records, this view only asks),\\n// then a small cut editor over the recording kept on this device, then one\\n// run that hands the result on. Built into src/view.js with the cut model.\\nconst $ = id => document.getElementById(id);\\nconst panels = { ready: $('ready'), live: $('live'), editor: $('editor'), done: $('done') };\\nconst player = $('player'), timeline = $('timeline'), segmentsNode = $('segments'), playhead = $('playhead');\\nconst state = { phase: 'ready', source: null, segments: [], undo: [], selected: null, duration: 0, previewUrl: null, dragging: null, poll: null, busy: false, lastSaved: null, device: null };\\n\\nfunction show(phase) {\\n state.phase = phase;\\n for (const [name, node] of Object.entries(panels)) node.hidden = name !== phase;\\n}\\nconst say = (id, text) => { $(id).textContent = text || ''; };\\nconst message = error => String(error && error.message || error || 'Something went wrong.');\\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\\nconst busy = value => { state.busy = value; for (const id of ['start', 'stop', 'discard', 'cut', 'toggle', 'undo', 'save', 'again', 'edit-again', 'record-again']) $(id).disabled = value || ($(id).dataset.off === 'true'); };\\n\\n// ── What fits here ─────────────────────────────────────────────────────────\\nconst minutes = seconds => Math.max(1, Math.round(seconds / 60));\\nfunction describeDevice(device) {\\n if (!device) return '';\\n if (!device.recorder) return 'This browser cannot record. Use a current desktop browser or StillMade Desktop.';\\n const notes = [];\\n if (device.platform === 'desktop') notes.push('Records straight to disk; up to ' + minutes(device.budget.seconds) + ' minutes per recording.');\\n else if (device.budget.seconds < 60) notes.push('Less than a minute fits on this device. Free up storage or use StillMade Desktop.');\\n else notes.push('About ' + minutes(device.budget.seconds) + ' minutes at 1080p fit on this device' + (device.budget.bytes ? ' (' + formatBytes(device.budget.bytes) + ')' : '') + '. StillMade Desktop records longer.');\\n if (device.cut === 'playback') notes.push('Cuts here take as long as the part you keep.');\\n if (device.cut === 'none') notes.push('This browser cannot cut recordings; the whole recording can still be used, or cut it in StillMade Desktop.');\\n return notes.join(' ');\\n}\\nasync function loadDevice() {\\n let problem = null;\\n try { state.device = await StillMade.desktop('device.info'); } catch (error) { state.device = null; problem = message(error); }\\n say('capacity', state.device ? describeDevice(state.device) : `Could not check what fits on this device: ${problem}`);\\n $('capacity').dataset.warn = String(!!problem || (!!state.device && (!state.device.recorder || state.device.budget.seconds < 60)));\\n}\\nfunction describeLive(status) {\\n const limits = status.limits || {};\\n const parts = [];\\n let warn = false;\\n if (limits.seconds) {\\n const left = Math.max(0, limits.seconds - (status.elapsed || 0) / 1000);\\n if (left <= limits.seconds * 0.2) { warn = true; parts.push(formatTime(left) + ' left before the time limit.'); }\\n }\\n if (limits.bytes) {\\n const left = Math.max(0, limits.bytes - (status.bytes || 0));\\n if (left <= limits.bytes * 0.2) { warn = true; parts.push(formatBytes(left) + ' of space left here. The recording stops and is kept when it runs out.'); }\\n }\\n return { text: parts.join(' '), warn };\\n}\\n\\n// ── Record ─────────────────────────────────────────────────────────────────\\nasync function pollStatus() {\\n if (state.phase !== 'live') return;\\n try {\\n const status = await StillMade.desktop('record.status');\\n if (state.phase !== 'live') return;\\n if (status.active) {\\n say('timer', formatTime((status.elapsed || 0) / 1000));\\n say('size', formatBytes(status.bytes));\\n const live = describeLive(status);\\n say('live-capacity', status.ended ? '' : live.text); $('live-capacity').dataset.warn = String(live.warn && !status.ended);\\n if (status.ended) say('live-status', `${status.message || 'The recording stopped.'} Press Stop and edit to keep it.`);\\n }\\n } catch (error) { say('live-status', message(error)); }\\n state.poll = setTimeout(pollStatus, 1000);\\n}\\n$('start').addEventListener('click', async () => {\\n busy(true);\\n say('ready-status', 'Press Start in the StillMade controls above, then choose what to record.');\\n try {\\n await StillMade.desktop('record.start', { kind: 'screen', microphone: $('mic').checked, systemAudio: $('system').checked, resolution: $('resolution').value });\\n say('timer', '0:00.0'); say('size', '0 KB'); say('live-status', 'Recording. Press Stop and edit when you are done.'); say('live-capacity', '');\\n show('live'); pollStatus();\\n } catch (error) { say('ready-status', message(error)); }\\n finally { busy(false); }\\n});\\n$('stop').addEventListener('click', async () => {\\n busy(true); clearTimeout(state.poll);\\n say('live-status', 'Saving the recording on this device…');\\n try {\\n const result = await StillMade.desktop('record.stop');\\n const asset = { ...result.asset };\\n for (const key of ['duration', 'width', 'height']) if (result[key] && !asset[key]) asset[key] = result[key];\\n await openEditor(asset);\\n say('editor-status', result.stoppedBy ? result.message : '');\\n } catch (error) { say('live-status', message(error)); state.phase = 'live'; pollStatus(); }\\n finally { busy(false); }\\n});\\n$('discard').addEventListener('click', async () => {\\n busy(true); clearTimeout(state.poll);\\n try { await StillMade.desktop('record.cancel'); } catch (error) { /* already gone */ }\\n say('ready-status', 'Discarded. Ready to record again.'); show('ready'); busy(false);\\n});\\nconst recordAgain = () => { closePreview(); state.source = null; say('ready-status', 'Ready to record.'); show('ready'); loadDevice(); };\\n$('again').addEventListener('click', recordAgain);\\n$('record-again').addEventListener('click', recordAgain);\\n\\n// ── Editor ─────────────────────────────────────────────────────────────────\\nfunction closePreview() {\\n player.pause();\\n if (state.previewUrl) { try { StillMade.releaseMedia(state.previewUrl); } catch (error) { /* released */ } state.previewUrl = null; }\\n player.removeAttribute('src'); player.load();\\n}\\nasync function openEditor(source) {\\n closePreview();\\n state.source = source; state.selected = null; state.undo = [];\\n const { url } = await StillMade.previewMedia(source);\\n state.previewUrl = url;\\n const duration = await new Promise((resolve, reject) => {\\n const done = () => { player.onloadedmetadata = null; player.onerror = null; resolve(Number.isFinite(player.duration) && player.duration > 0 ? player.duration : Number(source.duration) || 0); };\\n player.onloadedmetadata = done;\\n player.onerror = () => reject(new Error('This recording could not be opened for editing.'));\\n player.src = url;\\n });\\n if (!(duration > 0)) throw new Error('The recording has no length to edit.');\\n state.duration = duration;\\n state.segments = createSegments(duration);\\n renderTimeline(); updateTime(); updateButtons();\\n say('editor-status', '');\\n show('editor');\\n}\\nconst fraction = time => Math.max(0, Math.min(1, time / state.duration));\\nfunction renderTimeline() {\\n segmentsNode.replaceChildren();\\n state.segments.forEach((segment, index) => {\\n const node = document.createElement('button');\\n node.type = 'button'; node.className = 'sr-seg'; node.dataset.id = String(segment.id); node.dataset.kept = String(segment.kept);\\n node.setAttribute('aria-pressed', String(state.selected === segment.id));\\n node.setAttribute('aria-label', `${segment.kept ? 'Kept' : 'Removed'} part from ${formatTime(segment.start)} to ${formatTime(segment.end)}`);\\n node.style.left = `${fraction(segment.start) * 100}%`; node.style.width = `${(fraction(segment.end) - fraction(segment.start)) * 100}%`;\\n node.addEventListener('click', event => { state.selected = state.selected === segment.id ? null : segment.id; seekTo(timeAt(event.clientX)); renderTimeline(); updateButtons(); });\\n segmentsNode.append(node);\\n if (index > 0) {\\n const handle = document.createElement('button');\\n handle.type = 'button'; handle.className = 'sr-handle'; handle.dataset.index = String(index);\\n handle.setAttribute('aria-label', `Move the cut at ${formatTime(segment.start)}`);\\n handle.style.left = `${fraction(segment.start) * 100}%`;\\n handle.addEventListener('pointerdown', beginDrag);\\n segmentsNode.append(handle);\\n }\\n });\\n say('kept', isWhole(state.segments) ? `${formatTime(state.duration)} kept` : `${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)} kept`);\\n}\\nfunction timeAt(clientX) {\\n const box = timeline.getBoundingClientRect();\\n return box.width > 0 ? Math.max(0, Math.min(state.duration, (clientX - box.left) / box.width * state.duration)) : 0;\\n}\\nfunction beginDrag(event) {\\n event.preventDefault(); event.stopPropagation();\\n const index = Number(event.currentTarget.dataset.index);\\n state.dragging = { index, before: state.segments, handle: event.currentTarget, pointerId: event.pointerId };\\n event.currentTarget.setPointerCapture(event.pointerId);\\n event.currentTarget.dataset.active = 'true';\\n event.currentTarget.addEventListener('pointermove', continueDrag);\\n event.currentTarget.addEventListener('pointerup', endDrag);\\n event.currentTarget.addEventListener('pointercancel', endDrag);\\n}\\nfunction continueDrag(event) {\\n const drag = state.dragging; if (!drag) return;\\n const next = moveBoundary(state.segments, drag.index, timeAt(event.clientX));\\n if (next === state.segments) return;\\n state.segments = next;\\n const boundary = next[drag.index].start;\\n drag.handle.style.left = `${fraction(boundary) * 100}%`;\\n const left = segmentsNode.querySelector(`.sr-seg[data-id=\\\"${next[drag.index - 1].id}\\\"]`), right = segmentsNode.querySelector(`.sr-seg[data-id=\\\"${next[drag.index].id}\\\"]`);\\n if (left) left.style.width = `${(fraction(boundary) - fraction(next[drag.index - 1].start)) * 100}%`;\\n if (right) { right.style.left = `${fraction(boundary) * 100}%`; right.style.width = `${(fraction(next[drag.index].end) - fraction(boundary)) * 100}%`; }\\n seekTo(boundary, { quiet: true });\\n}\\nfunction endDrag(event) {\\n const drag = state.dragging; if (!drag) return;\\n state.dragging = null;\\n drag.handle.dataset.active = 'false';\\n drag.handle.removeEventListener('pointermove', continueDrag);\\n drag.handle.removeEventListener('pointerup', endDrag);\\n drag.handle.removeEventListener('pointercancel', endDrag);\\n try { drag.handle.releasePointerCapture(drag.pointerId); } catch (error) { /* released */ }\\n if (drag.before !== state.segments) { state.undo.push(drag.before); if (state.undo.length > 100) state.undo.shift(); }\\n renderTimeline(); updateButtons();\\n}\\ntimeline.addEventListener('pointerdown', event => { if (event.target === timeline || event.target === segmentsNode) seekTo(timeAt(event.clientX)); });\\nfunction seekTo(time, options) {\\n player.currentTime = Math.max(0, Math.min(state.duration, time));\\n if (!(options && options.quiet)) updateTime();\\n}\\nfunction updateTime() {\\n const time = player.currentTime || 0;\\n playhead.style.transform = `translateX(${fraction(time) * timeline.clientWidth}px)`;\\n say('time', `${formatTime(time)} / ${formatTime(state.duration)}`);\\n}\\nplayer.addEventListener('timeupdate', () => {\\n if (state.phase !== 'editor') return;\\n if ($('skip').checked && !player.paused) {\\n const next = nextKeptTime(state.segments, player.currentTime);\\n if (next === null) { player.pause(); }\\n else if (next !== player.currentTime) { player.currentTime = next; }\\n }\\n updateTime();\\n});\\nplayer.addEventListener('play', () => { if ($('skip').checked) { const next = nextKeptTime(state.segments, player.currentTime); if (next === null) player.currentTime = firstKeptTime(state.segments); else if (next !== player.currentTime) player.currentTime = next; } $('play').textContent = 'Pause'; });\\nplayer.addEventListener('pause', () => { $('play').textContent = 'Play'; });\\nplayer.addEventListener('ended', () => { $('play').textContent = 'Play'; });\\n$('play').addEventListener('click', () => { if (player.paused) player.play().catch(error => say('editor-status', message(error))); else player.pause(); });\\nfunction apply(next) {\\n if (next === state.segments) return;\\n state.undo.push(state.segments); if (state.undo.length > 100) state.undo.shift();\\n state.segments = next;\\n renderTimeline(); updateButtons();\\n}\\n$('cut').addEventListener('click', () => { apply(splitAt(state.segments, player.currentTime)); });\\n$('toggle').addEventListener('click', () => { if (state.selected !== null) apply(toggleKept(state.segments, state.selected)); });\\n$('undo').addEventListener('click', () => { const previous = state.undo.pop(); if (previous) { state.segments = previous; if (!previous.some(segment => segment.id === state.selected)) state.selected = null; renderTimeline(); updateButtons(); } });\\ntimeline.addEventListener('keydown', event => {\\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); seekTo((player.currentTime || 0) + (event.key === 'ArrowLeft' ? -0.5 : 0.5) * (event.shiftKey ? 10 : 1)); }\\n else if (event.key === 'c' || event.key === 'C') { event.preventDefault(); $('cut').click(); }\\n else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); $('toggle').click(); }\\n else if (event.key === ' ') { event.preventDefault(); $('play').click(); }\\n});\\nfunction updateButtons() {\\n const selected = state.segments.find(segment => segment.id === state.selected);\\n $('toggle').disabled = !selected; $('toggle').dataset.off = String(!selected);\\n $('toggle').textContent = selected && !selected.kept ? 'Keep part' : 'Remove part';\\n $('undo').disabled = !state.undo.length; $('undo').dataset.off = String(!state.undo.length);\\n const whole = isWhole(state.segments), nothing = keepRanges(state.segments).length === 0;\\n $('save').textContent = whole ? 'Use the whole recording' : 'Save the cut';\\n $('save').disabled = nothing; $('save').dataset.off = String(nothing);\\n}\\nwindow.addEventListener('resize', () => { if (state.phase === 'editor') updateTime(); });\\n\\n// ── Save ───────────────────────────────────────────────────────────────────\\n$('save').addEventListener('click', async () => {\\n const source = state.source; if (!source) return;\\n const keep = keepRanges(state.segments);\\n if (!keep.length) { say('editor-status', 'Keep at least one part.'); return; }\\n busy(true); player.pause();\\n try {\\n let video = source;\\n if (!isWhole(state.segments)) {\\n say('editor-status', 'Cutting on this device…');\\n let watching = true;\\n const watch = async () => { while (watching) { await sleep(700); if (!watching) break; try { const status = await StillMade.desktop('record.status'); if (status.cutting) say('editor-status', `Cutting on this device… ${Math.round(status.cutting.progress * 100)}%`); } catch (error) { /* next tick */ } } };\\n watch();\\n try { video = (await StillMade.desktop('record.cut', { source, keep })).asset; }\\n finally { watching = false; }\\n }\\n say('editor-status', 'Passing the result on…');\\n await StillMade.run({ video, original: source });\\n state.lastSaved = video;\\n say('done-text', isWhole(state.segments) ? `The whole recording (${formatTime(state.duration)}) is the result.` : `Saved ${formatTime(keptDuration(state.segments))} of ${formatTime(state.duration)}. The uncut recording is kept beside it.`);\\n show('done');\\n } catch (error) { say('editor-status', message(error)); }\\n finally { busy(false); }\\n});\\n$('edit-again').addEventListener('click', () => { if (state.source) { show('editor'); say('editor-status', ''); } });\\n\\n// ── Input: a video from an earlier Step opens straight in the editor ──────\\n// onInput first fires once the view is ready, which is also the earliest moment a host call can be made.\\nStillMade.onInput(async input => {\\n if (!state.device && !state.deviceAsked) { state.deviceAsked = true; loadDevice(); }\\n const video = input && input.video;\\n if (!video || state.source || state.phase !== 'ready') return;\\n busy(true);\\n try { await openEditor(video); say('editor-status', 'Editing the video this Block received. Record again to replace it.'); }\\n catch (error) { say('ready-status', message(error)); }\\n finally { busy(false); }\\n});\\n\\n})();\\n\"\n}"
|
|
217
|
+
},
|
|
218
|
+
"lineage": {
|
|
219
|
+
"parents": [],
|
|
220
|
+
"changes": "Packaged original Block source for StillMade."
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|