stillmade-sdk 0.1.48 → 0.1.50

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.
Files changed (60) hide show
  1. package/AGENTS.md +6 -2
  2. package/AGENT_CONTROL.md +15 -0
  3. package/FRAME_VIEWS.md +24 -0
  4. package/INTERFACE_DESIGN.md +40 -5
  5. package/MODULES.md +2 -2
  6. package/PASTE_TO_LLM.md +26 -19
  7. package/SDK.md +126 -39
  8. package/UI_AESTHETIC.md +15 -0
  9. package/blocks/generate/package.js +1 -1
  10. package/blocks/generate/versions/1.1.0/package.js +471 -0
  11. package/blocks/generate/versions/1.2.0/package.js +555 -0
  12. package/blocks/native/sm.board-editor/versions/3.0.0/package.js +50 -17
  13. package/blocks/native/sm.board-editor/versions/3.1.0/package.js +1037 -0
  14. package/blocks/native/sm.editor/versions/3.0.0/package.js +128 -83
  15. package/blocks/native/sm.editor/versions/3.1.0/package.js +1299 -0
  16. package/blocks/native/sm.panel-strip/versions/3.0.0/package.js +45 -12
  17. package/blocks/native/sm.panel-strip/versions/3.1.0/package.js +479 -0
  18. package/blocks/native/sm.voiceover/versions/3.0.0/package.js +9 -3
  19. package/docs/build/block-builder-contract.md +6 -2
  20. package/docs/build/frame-views.md +24 -0
  21. package/docs/build/interface-design.md +40 -5
  22. package/docs/build/modules.md +2 -2
  23. package/docs/reference/authoring-guide.md +4 -4
  24. package/docs/reference/execution.md +15 -1
  25. package/docs/reference/first-party-blocks.md +4 -4
  26. package/docs/reference/parity.md +1 -1
  27. package/docs/reference/ports.md +1 -1
  28. package/docs/reference/view-bridge.md +3 -0
  29. package/docs/tools/llm.md +26 -19
  30. package/package.json +1 -1
  31. package/packages/block-cli/dev-host/assets/host.js +161 -33
  32. package/packages/block-cli/dev-host/assets/index.css +1 -1
  33. package/packages/block-platform/block-readiness.js +16 -3
  34. package/packages/block-platform/canvas.js +13 -1
  35. package/packages/block-platform/catalog.js +32 -5
  36. package/packages/block-platform/flow-inspection.js +16 -2
  37. package/packages/block-platform/listings.js +17 -3
  38. package/packages/block-platform/steps.js +4 -1
  39. package/packages/block-sdk/admission.js +1 -1
  40. package/packages/block-sdk/agent-inputs.js +4 -2
  41. package/packages/block-sdk/builder-contract.js +7 -4
  42. package/packages/block-sdk/first-party-identities.js +9 -1
  43. package/packages/block-sdk/first-party-packages.js +11 -1
  44. package/packages/block-sdk/generation-catalog.js +55 -5
  45. package/packages/block-sdk/generation-models.js +60 -0
  46. package/packages/block-sdk/index.d.ts +15 -1
  47. package/packages/block-sdk/index.js +2 -1
  48. package/packages/block-sdk/input-source-policy.js +2 -1
  49. package/packages/block-sdk/module-hosted.js +13 -5
  50. package/packages/block-sdk/module-worker.js +1 -1
  51. package/packages/block-sdk/port-types.js +6 -2
  52. package/packages/block-sdk/semantic-contracts.d.ts +1 -1
  53. package/packages/block-sdk/semantic-relationships.js +3 -0
  54. package/packages/block-sdk/semantic-schema.js +4 -1
  55. package/packages/block-sdk/shared-controls.js +4 -1
  56. package/packages/block-sdk/staged-releases.js +2 -1
  57. package/packages/block-sdk/view-bridge.js +3 -0
  58. package/packages/block-sdk/view-design.js +54 -10
  59. package/packages/block-sdk/view-renderer.js +19 -6
  60. package/packages/design-system/block-kit.css +12 -3
@@ -0,0 +1,555 @@
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.generate",
7
+ "version": "1.2.0",
8
+ "name": "Generate",
9
+ "description": "Generate images and videos with any model StillMade runs: pictures from a prompt or your own photos, clips from a start and end frame. Pick the model, shape, size, length and camera move, make up to four at once, and use any result as a photo or frame for the next.",
10
+ "kind": "task",
11
+ "runtime": "module",
12
+ "license": "MIT",
13
+ "inputs": {
14
+ "prompt": {
15
+ "type": "text",
16
+ "primary": true,
17
+ "required": true,
18
+ "semantic": "generation_prompt",
19
+ "accepts": [
20
+ "written_message",
21
+ "summary_report",
22
+ "narration_script"
23
+ ],
24
+ "sources": [
25
+ "upstream",
26
+ "user"
27
+ ],
28
+ "description": "What to make: the picture, or the shot and how it moves."
29
+ },
30
+ "mode": {
31
+ "type": "text",
32
+ "required": false,
33
+ "semantic": "generation_mode",
34
+ "description": "Image or video."
35
+ },
36
+ "model": {
37
+ "type": "text",
38
+ "required": false,
39
+ "semantic": "generation_model",
40
+ "description": "The model that makes it, from the models StillMade runs. Leave it empty and StillMade picks one that makes what you asked for."
41
+ },
42
+ "aspectRatio": {
43
+ "type": "text",
44
+ "required": false,
45
+ "semantic": "aspect_ratio",
46
+ "description": "The shape, such as 16:9 for wide or 9:16 for a phone screen."
47
+ },
48
+ "size": {
49
+ "type": "text",
50
+ "required": false,
51
+ "semantic": "render_resolution",
52
+ "description": "How large it is, such as 2K for a picture or 1080p for a video."
53
+ },
54
+ "quality": {
55
+ "type": "text",
56
+ "required": false,
57
+ "semantic": "render_quality",
58
+ "description": "Low, medium or high, for the models that offer it. Higher costs more."
59
+ },
60
+ "duration": {
61
+ "type": "integer",
62
+ "required": false,
63
+ "semantic": "duration_seconds",
64
+ "description": "How long the video is, in seconds."
65
+ },
66
+ "sound": {
67
+ "type": "boolean",
68
+ "required": false,
69
+ "semantic": "generate_audio",
70
+ "description": "Whether the video comes with its own sound, for the models that make it."
71
+ },
72
+ "camera": {
73
+ "type": "text",
74
+ "required": false,
75
+ "semantic": "camera_motion",
76
+ "description": "How the camera moves in the video, such as a slow push in or a pan left. It is added to the end of the prompt."
77
+ },
78
+ "settings": {
79
+ "type": "json",
80
+ "schema": "json",
81
+ "required": false,
82
+ "semantic": "generation_settings",
83
+ "sources": [
84
+ "view"
85
+ ],
86
+ "description": "Other settings StillMade lists for the chosen model, by name. The screen fills this in."
87
+ },
88
+ "count": {
89
+ "type": "integer",
90
+ "required": false,
91
+ "semantic": "generation_count",
92
+ "description": "How many to make at once, from 1 to 4. Each one is priced."
93
+ },
94
+ "avoid": {
95
+ "type": "text",
96
+ "required": false,
97
+ "semantic": "negative_prompt",
98
+ "description": "What to leave out of it."
99
+ },
100
+ "seed": {
101
+ "type": "integer",
102
+ "required": false,
103
+ "semantic": "generation_seed",
104
+ "description": "A number that makes the same choices again. Leave it empty for a new result each time."
105
+ },
106
+ "references": {
107
+ "type": "image[]",
108
+ "required": false,
109
+ "semantic": "reference_image",
110
+ "sources": [
111
+ "upstream",
112
+ "user"
113
+ ],
114
+ "description": "Your own photos the picture keeps, such as a product, a person or a place."
115
+ },
116
+ "startFrame": {
117
+ "type": "image",
118
+ "required": false,
119
+ "semantic": "first_frame",
120
+ "sources": [
121
+ "upstream",
122
+ "user"
123
+ ],
124
+ "description": "The picture the video starts on."
125
+ },
126
+ "endFrame": {
127
+ "type": "image",
128
+ "required": false,
129
+ "semantic": "last_frame",
130
+ "sources": [
131
+ "upstream",
132
+ "user"
133
+ ],
134
+ "description": "The picture the video ends on. It needs a start frame."
135
+ }
136
+ },
137
+ "outputs": {
138
+ "media": {
139
+ "type": "asset[]",
140
+ "primary": true,
141
+ "semantic": "generated_media",
142
+ "description": "The pictures or clips that were made, in order."
143
+ },
144
+ "notMade": {
145
+ "type": "text",
146
+ "required": false,
147
+ "semantic": "generation_failures",
148
+ "description": "What could not be made, and why. Nothing is charged for those."
149
+ }
150
+ },
151
+ "permissions": {
152
+ "project": [],
153
+ "actions": [
154
+ "media.generate"
155
+ ],
156
+ "network": [],
157
+ "filesystem": [],
158
+ "secrets": []
159
+ },
160
+ "features": [
161
+ {
162
+ "id": "generate-image",
163
+ "label": "Generate pictures",
164
+ "does": "Makes up to four pictures with the image model you pick, in its shapes and sizes, keeping up to eight of your own photos.",
165
+ "asks": [
166
+ "make a picture of my product on a beach",
167
+ "generate four images of a cozy cafe",
168
+ "make a 9:16 poster with my logo"
169
+ ],
170
+ "inputs": [
171
+ "prompt",
172
+ "model",
173
+ "aspectRatio",
174
+ "size",
175
+ "quality",
176
+ "settings",
177
+ "count",
178
+ "references",
179
+ "avoid",
180
+ "seed"
181
+ ],
182
+ "outputs": [
183
+ "media"
184
+ ]
185
+ },
186
+ {
187
+ "id": "generate-video",
188
+ "label": "Generate video clips",
189
+ "does": "Makes up to four clips with the video model you pick, at its lengths, sizes and shapes, from start and end frames, with a camera move and sound where offered.",
190
+ "asks": [
191
+ "make a 5 second clip of waves at sunset",
192
+ "animate this photo",
193
+ "make a vertical video from this first frame",
194
+ "slow push in on the product"
195
+ ],
196
+ "inputs": [
197
+ "prompt",
198
+ "model",
199
+ "aspectRatio",
200
+ "size",
201
+ "duration",
202
+ "sound",
203
+ "camera",
204
+ "settings",
205
+ "count",
206
+ "startFrame",
207
+ "endFrame",
208
+ "avoid",
209
+ "seed"
210
+ ],
211
+ "outputs": [
212
+ "media"
213
+ ]
214
+ }
215
+ ],
216
+ "platforms": {
217
+ "phone": {
218
+ "supported": true
219
+ },
220
+ "browser": {
221
+ "supported": true
222
+ },
223
+ "desktop": {
224
+ "supported": true
225
+ }
226
+ },
227
+ "ui": [
228
+ {
229
+ "port": "prompt",
230
+ "control": "text",
231
+ "multiline": true
232
+ },
233
+ {
234
+ "port": "mode",
235
+ "control": "select",
236
+ "options": [
237
+ "image",
238
+ "video"
239
+ ]
240
+ },
241
+ {
242
+ "port": "model",
243
+ "control": "text"
244
+ },
245
+ {
246
+ "port": "aspectRatio",
247
+ "control": "text"
248
+ },
249
+ {
250
+ "port": "size",
251
+ "control": "text"
252
+ },
253
+ {
254
+ "port": "quality",
255
+ "control": "select",
256
+ "options": [
257
+ "low",
258
+ "medium",
259
+ "high"
260
+ ]
261
+ },
262
+ {
263
+ "port": "duration",
264
+ "control": "number"
265
+ },
266
+ {
267
+ "port": "sound",
268
+ "control": "checkbox"
269
+ },
270
+ {
271
+ "port": "camera",
272
+ "control": "text"
273
+ },
274
+ {
275
+ "port": "count",
276
+ "control": "number"
277
+ },
278
+ {
279
+ "port": "avoid",
280
+ "control": "text"
281
+ },
282
+ {
283
+ "port": "seed",
284
+ "control": "number"
285
+ },
286
+ {
287
+ "port": "references",
288
+ "control": "asset"
289
+ },
290
+ {
291
+ "port": "startFrame",
292
+ "control": "asset"
293
+ },
294
+ {
295
+ "port": "endFrame",
296
+ "control": "asset"
297
+ }
298
+ ],
299
+ "provenance": {
300
+ "notice": "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"
301
+ }
302
+ },
303
+ "module": {
304
+ "schemaVersion": 1,
305
+ "entry": "main.js",
306
+ "files": {
307
+ "main.js": {
308
+ "sha256": "3fa22e634644a445a2b7b15dc77017b69873558c86ec5cd05bf13e0744bc02b8",
309
+ "bytes": 4052,
310
+ "mediaType": "text/javascript"
311
+ }
312
+ }
313
+ },
314
+ "moduleSource": {
315
+ "main.js": "// Generate: every image and video model StillMade runs, in one Block. The view sets the choices; this run turns\n// them into one media.generate batch. StillMade prices the whole batch, the person approves it once, and the\n// pictures or clips come back here in order.\nconst clean = value => typeof value === 'string' && value && value !== 'default' ? value : undefined;\n// 1.1.0: a setting StillMade lists for a model beyond the ones named below arrives in `settings` by its key and\n// goes to StillMade as it is, so a setting added later works without a new version. The named inputs win.\nconst otherSettings = value => value && typeof value === 'object' && !Array.isArray(value)\n ? Object.fromEntries(Object.entries(value).filter(([key, item]) => key !== 'model' && /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(key) && ['string', 'number', 'boolean'].includes(typeof item)).slice(0, 20))\n : {};\n// 1.2.0: the camera move the person picked for a clip (\"slow push in\") is said at the end of the prompt, as a director\n// would say it: \"Camera: slow push in.\" A picture has no camera move, so it is left out of an image.\nconst cameraLine = (mode, value) => {\n const camera = mode === 'video' && typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim().replace(/[.\\s]+$/, '').slice(0, 200) : '';\n return camera ? `Camera: ${camera}.` : '';\n};\n\nexport function generationItems(input) {\n const mode = input.mode === 'video' ? 'video' : 'image';\n const described = String(input.prompt || '').trim();\n if (!described) throw new Error(mode === 'video' ? 'Describe the shot first.' : 'Describe the picture first.');\n const camera = cameraLine(mode, input.camera);\n const prompt = camera ? `${described}${/[.!?]$/.test(described) ? '' : '.'} ${camera}` : described;\n if (mode === 'video' && input.endFrame && !input.startFrame) throw new Error('Add a start frame before an end frame.');\n const count = Math.max(1, Math.min(4, Math.floor(Number(input.count) || 1)));\n const settings = otherSettings(input.settings);\n if (clean(input.model)) settings.model = input.model;\n if (clean(input.aspectRatio)) settings.aspectRatio = input.aspectRatio;\n if (clean(input.size)) settings.resolution = input.size;\n if (mode === 'image' && clean(input.quality)) settings.quality = input.quality;\n if (mode === 'video') {\n const seconds = Math.floor(Number(input.duration));\n if (Number.isFinite(seconds) && seconds > 0) settings.duration = seconds;\n if (typeof input.sound === 'boolean') settings.generateAudio = input.sound;\n }\n const references = Array.isArray(input.references) ? input.references.filter(Boolean).slice(0, 8) : [];\n const avoid = String(input.avoid || '').trim();\n const seed = Number.isInteger(input.seed) && input.seed >= 0 ? input.seed : undefined;\n const items = [];\n for (let index = 0; index < count; index++) {\n const item = { key: `${mode}-${index + 1}`, operation: mode, prompt };\n if (Object.keys(settings).length) item.settings = settings;\n if (mode === 'image' && references.length) item.references = references;\n if (mode === 'video' && input.startFrame) item.firstFrame = input.startFrame;\n if (mode === 'video' && input.startFrame && input.endFrame) item.lastFrame = input.endFrame;\n if (avoid) item.negativePrompt = avoid;\n if (seed !== undefined) item.seed = Math.min(2147483647, seed + index);\n items.push(item);\n }\n return items;\n}\n\nexport default async function run(input, stillmade) {\n const items = generationItems(input);\n stillmade.progress(0.05, items.length > 1 ? `Making ${items.length}` : 'Making it');\n const { value, failures } = await stillmade.actions.run({\n schemaVersion: 1, kind: 'stillmade.media-action', operation: 'media.generate', resultPort: null, request: { items },\n });\n const media = value.map(({ key, ...made }) => made);\n const outputs = { media };\n if (failures && failures.length) outputs.notMade = `${failures.length} of ${items.length} were not made: ${failures.map(failure => failure.message).join(' ')}`.slice(0, 2000);\n return outputs;\n}\n"
316
+ },
317
+ "view": {
318
+ "runtime": "frame",
319
+ "html": "<main class=\"sm-block gen\" id=\"root\" data-mode=\"image\" data-models=\"loading\">\n <div class=\"sm-fields\">\n <fieldset class=\"sm-choices gen-mode\" data-segmented id=\"mode\">\n <legend class=\"sm-sr\">Make</legend>\n <label><input type=\"radio\" name=\"mode\" value=\"image\" checked><span class=\"sm-glyph\" data-glyph=\"image\" aria-hidden=\"true\"></span><span>Image</span></label>\n <label><input type=\"radio\" name=\"mode\" value=\"video\"><span class=\"sm-glyph\" data-glyph=\"video\" aria-hidden=\"true\"></span><span>Video</span></label>\n </fieldset>\n\n <div class=\"gen-models-row\" id=\"models-row\">\n <p class=\"sm-status\" id=\"models-status\" role=\"status\" data-state=\"loading\">Loading models</p>\n <button type=\"button\" class=\"sm-quiet\" id=\"models-retry\" data-stillmade-action=\"load-models\" hidden>Try again</button>\n </div>\n <details class=\"gen-model\" id=\"model-picker\">\n <summary class=\"gen-card\" id=\"model-card\" aria-label=\"Choose the model\">\n <span class=\"gen-card-text\">\n <strong id=\"model-name\"></strong>\n <span class=\"gen-tags\" id=\"model-tags\"></span>\n </span>\n <span class=\"sm-glyph\" data-glyph=\"chevron\" aria-hidden=\"true\"></span>\n </summary>\n <div class=\"gen-models\" id=\"models\">\n <fieldset class=\"sm-choices gen-only-image gen-model-list\" id=\"imageModel\"><legend>Image models</legend></fieldset>\n <fieldset class=\"sm-choices gen-only-video gen-model-list\" id=\"videoModel\"><legend>Video models</legend></fieldset>\n </div>\n </details>\n\n <div class=\"sm-field gen-prompt\">\n <label class=\"sm-sr\" for=\"prompt\">Describe it</label>\n <textarea id=\"prompt\" rows=\"4\" maxlength=\"4000\" placeholder=\"Describe the picture\"></textarea>\n </div>\n\n <div class=\"sm-field gen-only-image\" id=\"references-row\">\n <span class=\"sm-label\">Your photos <small class=\"gen-muted\" id=\"references-limit\"></small></span>\n <ul class=\"sm-gallery\" data-size=\"small\" id=\"references\" aria-label=\"Your photos\">\n <li id=\"references-add\"><button type=\"button\" class=\"sm-add\" data-stillmade-add=\"references\" aria-label=\"Add photos\" title=\"Add photos\"></button></li>\n </ul>\n </div>\n\n <div class=\"gen-pair gen-only-video\" id=\"frames-row\">\n <div class=\"sm-field\" id=\"start-slot\">\n <span class=\"sm-label\">Start frame <small class=\"gen-needed\" id=\"start-needed\" hidden>Needed</small></span>\n <ul class=\"sm-gallery gen-slot-tile\" data-size=\"small\" id=\"start-tile\" aria-label=\"Start frame\">\n <li class=\"gen-slot-add\"><button type=\"button\" class=\"sm-add\" data-stillmade-add=\"startFrame\" aria-label=\"Add a start frame\" title=\"Add a start frame\"></button></li>\n </ul>\n </div>\n <div class=\"sm-field\" id=\"end-slot\">\n <span class=\"sm-label\">End frame</span>\n <ul class=\"sm-gallery gen-slot-tile\" data-size=\"small\" id=\"end-tile\" aria-label=\"End frame\">\n <li class=\"gen-slot-add\"><button type=\"button\" class=\"sm-add\" data-stillmade-add=\"endFrame\" aria-label=\"Add an end frame\" title=\"Add an end frame\"></button></li>\n </ul>\n </div>\n </div>\n </div>\n\n <div class=\"sm-settings\" id=\"settings\">\n <fieldset class=\"sm-choices gen-row gen-only-video\" id=\"camera\">\n <legend>Camera</legend>\n <label title=\"The camera holds still\"><input type=\"radio\" name=\"camera\" value=\"static, locked off\"><span>Static</span></label>\n <label title=\"Moves slowly toward the subject\"><input type=\"radio\" name=\"camera\" value=\"slow push in\"><span>Push in</span></label>\n <label title=\"Moves slowly away from the subject\"><input type=\"radio\" name=\"camera\" value=\"slow pull out\"><span>Pull out</span></label>\n <label title=\"Turns to the left\"><input type=\"radio\" name=\"camera\" value=\"pan left\"><span>Pan left</span></label>\n <label title=\"Turns to the right\"><input type=\"radio\" name=\"camera\" value=\"pan right\"><span>Pan right</span></label>\n <label title=\"Tilts upward\"><input type=\"radio\" name=\"camera\" value=\"tilt up\"><span>Tilt up</span></label>\n <label title=\"Circles around the subject\"><input type=\"radio\" name=\"camera\" value=\"orbit around the subject\"><span>Orbit</span></label>\n <label title=\"Rises up and over\"><input type=\"radio\" name=\"camera\" value=\"crane up\"><span>Crane up</span></label>\n <label title=\"Held by hand, a little shake\"><input type=\"radio\" name=\"camera\" value=\"handheld\"><span>Handheld</span></label>\n </fieldset>\n <fieldset class=\"sm-choices gen-row\" data-segmented id=\"count\">\n <legend>How many</legend>\n <label><input type=\"radio\" name=\"count\" value=\"1\" checked><span>1</span></label>\n <label><input type=\"radio\" name=\"count\" value=\"2\"><span>2</span></label>\n <label><input type=\"radio\" name=\"count\" value=\"3\"><span>3</span></label>\n <label><input type=\"radio\" name=\"count\" value=\"4\"><span>4</span></label>\n </fieldset>\n </div>\n\n <details class=\"sm-more\" id=\"more\">\n <summary>More</summary>\n <div class=\"sm-fields\">\n <div class=\"sm-field\"><label for=\"avoid\">Leave out</label><input id=\"avoid\" type=\"text\" maxlength=\"2000\" placeholder=\"Blur, text, extra fingers\"></div>\n <div class=\"sm-field\" id=\"seed-row\"><label for=\"seed\">Seed</label><input id=\"seed\" type=\"number\" min=\"0\" max=\"2147483647\" step=\"1\" inputmode=\"numeric\" placeholder=\"Random\"></div>\n </div>\n </details>\n\n <section class=\"sm-stage gen-stage\" id=\"stage\" aria-label=\"What was made\">\n <p class=\"sm-status\" id=\"status\" role=\"status\"></p>\n <div class=\"sm-actions\">\n <button type=\"button\" class=\"sm-icon\" id=\"download\" data-stillmade-action=\"download\" aria-label=\"Download\" title=\"Download\" hidden><span class=\"sm-glyph\" data-glyph=\"download\" aria-hidden=\"true\"></span></button>\n <button type=\"button\" class=\"sm-primary\" id=\"generate\" data-stillmade-action=\"run\">Generate image</button>\n </div>\n <div class=\"gen-shown\" id=\"shown\" aria-live=\"polite\">\n <div class=\"gen-place\" id=\"place\">\n <span class=\"sm-shape gen-place-shape\" id=\"place-shape\" style=\"--r:1/1\" aria-hidden=\"true\"></span>\n <div class=\"sm-empty gen-place-text\"><span class=\"sm-glyph\" data-glyph=\"image\" id=\"place-glyph\" aria-hidden=\"true\"></span><p id=\"place-words\">Your picture appears here</p></div>\n </div>\n </div>\n <ul class=\"gen-strip\" id=\"strip\" aria-label=\"Made in this Step\" hidden></ul>\n </section>\n</main>\n",
320
+ "css": "/* Generate 1.2.0: the Block kit (injected by the host) lays the page out (the choices on the left, the stage on the right,\n stacked under 860px), carries fields, choices, galleries, buttons, glyphs and the stage's status and actions in its top\n corners. This file draws only what the kit has no piece for: the model card and its cards, the setting rows with their\n name beside the picks, the empty place in the coming shape, the shown result with its reuse tools, and the strip of earlier\n results. Only theme tokens; pictures and picks are rounded rectangles. */\n.gen [hidden], .gen [data-off=\"true\"] { display: none !important; }\n.gen[data-mode=\"image\"] .gen-only-video, .gen[data-mode=\"video\"] .gen-only-image { display: none !important; }\n.gen > .sm-fields, .gen > .sm-settings { grid-template-columns: minmax(0, 1fr); gap: var(--s-3, 12px); }\n.gen-mode > label { flex: 1 1 0; }\n.gen-muted { color: var(--fg-muted); font-weight: 400; }\n.gen-prompt textarea { min-height: 96px; line-height: 1.45; }\n\n/* The model card: the chosen model's name and what it takes; it opens every model of the mode as cards. */\n.gen-models-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s-2, 8px); min-height: 44px; }\n.gen-model { position: relative; }\n.gen-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; min-height: 56px; padding: 8px 12px; border: 1px solid var(--border-strong); border-radius: var(--r-3, 8px); background: var(--bg-elev-1); color: var(--fg); cursor: pointer; list-style: none; }\n.gen-card::-webkit-details-marker { display: none; }\n.gen-card:hover { background: var(--bg-elev-2); }\n.gen-card:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }\n.gen-card-text { display: grid; gap: 4px; min-width: 0; }\n.gen-card strong { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.gen-card > .sm-glyph { --sm-glyph-size: 16px; color: var(--fg-muted); transition: transform var(--dur-fast, 160ms) var(--ease-out, ease); }\n.gen-model[open] > .gen-card > .sm-glyph { transform: rotate(180deg); }\n.gen-tags { display: flex; flex-wrap: wrap; gap: 4px; }\n.gen-tags > span { padding: 1px 7px; border: 1px solid var(--border-tok); border-radius: var(--r-2, 4px); color: var(--fg-muted); font-size: max(12px, var(--fs-caption, .8125rem)); line-height: 1.5; white-space: nowrap; }\n.gen-models { position: absolute; z-index: 6; top: calc(100% + 6px); left: 0; width: min(760px, calc(100vw - 32px)); max-height: 520px; overflow: auto; padding: var(--s-3, 12px); border: 1px solid var(--border-strong); border-radius: var(--r-3, 8px); background: var(--bg-elev-1); box-shadow: var(--shadow-2); }\n.gen-model-list { grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr)); }\n.gen-model-list > legend { grid-column: 1 / -1; }\n.gen-model-list > label { grid-template-columns: minmax(0, 1fr); justify-items: start; align-content: start; gap: 6px 8px; padding: 10px 12px; text-align: left; font-size: var(--fs-body-s, .875rem); }\n.gen-model-list > label > strong { font-weight: 500; color: inherit; }\n.gen-model-list > label:has(input:checked) .gen-tags > span { border-color: currentColor; }\n.gen-why { color: var(--warning); font-size: max(12px, var(--fs-caption, .8125rem)); }\n.gen-why:empty { display: none; }\n\n/* Start and end frames side by side, each a small tile; the start says when the model needs it. */\n.gen-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-3, 12px); }\n.gen-slot-tile { --sm-tile: 120px; grid-template-columns: minmax(0, 1fr); }\n.gen-needed { padding: 0 6px; border: 1px solid var(--warning); border-radius: var(--r-2, 4px); color: var(--warning); font-size: 12px; }\n\n/* A setting row: its name on the left, its picks beside it; picks that wrap stay in their own column. */\n.gen-row { --gen-label: 5.5rem; position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; width: auto; min-height: 36px; padding: 0 0 0 var(--gen-label); }\n.gen-row > legend { position: absolute; top: 0; left: 0; display: flex; align-items: center; width: calc(var(--gen-label) - 8px); height: 36px; margin: 0; padding: 0; color: var(--fg-muted); font-size: max(12px, var(--fs-caption, .8125rem)); line-height: 1.2; }\n.gen-row[data-segmented] { gap: 0; width: auto; }\n.gen-row:not([data-segmented]) > label { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 4px 11px; border-radius: var(--r-pill, 999px); font-size: var(--fs-body-s, .875rem); white-space: nowrap; }\n.gen-row .sm-shape { --sm-shape-size: 14px; margin: 0; border-width: 1.5px; }\n\n/* The stage: the shown result (or its place in the coming shape, or one placeholder per item while a run works), then the\n strip of earlier results. The kit keeps the band above for the status and the actions. */\n.gen-shown { display: grid; place-items: center; width: 100%; min-width: 0; }\n.gen-place { position: relative; display: grid; place-items: center; width: 100%; }\n.gen-place-shape { --sm-shape-size: min(420px, 72%); max-height: min(58vh, 540px); margin: 0; border: 1px dashed var(--border-strong); border-radius: var(--r-3, 8px); color: var(--fg-subtle); }\n.gen-place-text { position: absolute; inset: 0; align-content: center; }\n/* Two short lines, so the words stay inside a narrow shape (9:16 on a phone). */\n.gen-place-text > p { max-width: 13ch; }\n.gen-waits { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); justify-items: center; gap: var(--s-3, 12px); width: 100%; }\n.gen-wait { position: relative; width: min(100%, calc(min(58vh, 540px) * var(--ratio-n, 1))); aspect-ratio: var(--ratio, 1); border-radius: var(--r-3, 8px); overflow: hidden; background: var(--bg-elev-2); }\n.gen-figure { display: grid; justify-items: center; gap: 6px; width: 100%; min-width: 0; margin: 0; }\n/* Sized by the picture itself, so its box is the picture and StillMade's full-screen square sits on its corner. */\n.gen-figure > img, .gen-figure > video { display: block; width: auto; height: auto; max-width: 100%; max-height: min(58vh, 540px); border-radius: var(--r-2, 4px); object-fit: contain; }\n.gen-figure > .sm-media-off { width: min(100%, 360px); aspect-ratio: var(--ratio, 1); }\n.gen-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; width: 100%; min-width: 0; }\n.gen-cap { flex: 1 1 120px; min-width: 0; overflow: hidden; color: var(--fg-muted); font-size: max(12px, var(--fs-caption, .8125rem)); text-overflow: ellipsis; white-space: nowrap; }\n.gen-acts { display: flex; flex: none; gap: 2px; margin-inline-start: auto; }\n[data-stillmade-readonly] .gen-acts { display: none; }\n\n/* Earlier results: small picks of what was made, newest first; the one shown is marked. */\n.gen-strip { display: flex; gap: var(--s-2, 8px); width: 100%; margin: var(--s-2, 8px) 0 0; padding: 2px; overflow-x: auto; list-style: none; }\n.gen-strip > li { flex: none; }\n/* Centered under the result, and still scrolled from the first when it overflows. */\n.gen-strip > li:first-child { margin-inline-start: auto; }\n.gen-strip > li:last-child { margin-inline-end: auto; }\n.gen-strip button { display: grid; place-items: center; width: 64px; height: 64px; padding: 0; border: 0; border-radius: var(--r-2, 4px); overflow: hidden; background: var(--bg-elev-2); color: var(--fg-muted); cursor: pointer; }\n.gen-strip button[aria-current=\"true\"] { box-shadow: 0 0 0 2px var(--fg); }\n.gen-strip button:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }\n.gen-strip img, .gen-strip video { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }\n.gen-strip .sm-media-off { width: 100%; height: 100%; border: 0; }\n\n/* While a run works: its pulse an opacity change on a layer of its own. */\n.gen-pulse { position: absolute; inset: 0; background: var(--bg-elev-1); opacity: .35; animation: gen-pulse 1.6s ease-in-out infinite; }\n@keyframes gen-pulse { 50% { opacity: .9; } }\n\n/* Icons for a result's reuse tools, drawn with currentColor beside the kit's glyphs. */\n.gen-ico { --g: 20px; position: relative; display: inline-block; flex: none; width: var(--g); height: var(--g); color: inherit; }\n.gen-ico::before, .gen-ico::after { content: \"\"; position: absolute; box-sizing: border-box; }\n/* A start or end frame: a clip's outline with its first or last part filled. */\n.gen-ico[data-ico=\"start\"]::before, .gen-ico[data-ico=\"end\"]::before { inset: 22% 8%; border: 2px solid currentColor; border-radius: 2px; }\n.gen-ico[data-ico=\"start\"]::after { left: 8%; top: 22%; bottom: 22%; width: 34%; background: currentColor; border-radius: 2px 0 0 2px; }\n.gen-ico[data-ico=\"end\"]::after { right: 8%; top: 22%; bottom: 22%; width: 34%; background: currentColor; border-radius: 0 2px 2px 0; }\n.gen-ico[data-ico=\"again\"]::before { inset: 16%; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; }\n.gen-ico[data-ico=\"again\"]::after { left: 50%; top: 6%; width: 32%; height: 30%; background: currentColor; clip-path: polygon(0 0, 100% 50%, 0 100%); }\n\n@media (prefers-reduced-motion: reduce) {\n .gen-pulse { animation: none; opacity: .6; }\n .gen-card > .sm-glyph { transition: none; }\n}\n",
321
+ "javascript": "(()=>{\n\"use strict\";\n// Generate: every image and video model StillMade runs, in one Block. The view sets the choices; this run turns\n// them into one media.generate batch. StillMade prices the whole batch, the person approves it once, and the\n// pictures or clips come back here in order.\nconst clean = value => typeof value === 'string' && value && value !== 'default' ? value : undefined;\n// 1.1.0: a setting StillMade lists for a model beyond the ones named below arrives in `settings` by its key and\n// goes to StillMade as it is, so a setting added later works without a new version. The named inputs win.\nconst otherSettings = value => value && typeof value === 'object' && !Array.isArray(value)\n ? Object.fromEntries(Object.entries(value).filter(([key, item]) => key !== 'model' && /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(key) && ['string', 'number', 'boolean'].includes(typeof item)).slice(0, 20))\n : {};\n// 1.2.0: the camera move the person picked for a clip (\"slow push in\") is said at the end of the prompt, as a director\n// would say it: \"Camera: slow push in.\" A picture has no camera move, so it is left out of an image.\nconst cameraLine = (mode, value) => {\n const camera = mode === 'video' && typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim().replace(/[.\\s]+$/, '').slice(0, 200) : '';\n return camera ? `Camera: ${camera}.` : '';\n};\n\nfunction generationItems(input) {\n const mode = input.mode === 'video' ? 'video' : 'image';\n const described = String(input.prompt || '').trim();\n if (!described) throw new Error(mode === 'video' ? 'Describe the shot first.' : 'Describe the picture first.');\n const camera = cameraLine(mode, input.camera);\n const prompt = camera ? `${described}${/[.!?]$/.test(described) ? '' : '.'} ${camera}` : described;\n if (mode === 'video' && input.endFrame && !input.startFrame) throw new Error('Add a start frame before an end frame.');\n const count = Math.max(1, Math.min(4, Math.floor(Number(input.count) || 1)));\n const settings = otherSettings(input.settings);\n if (clean(input.model)) settings.model = input.model;\n if (clean(input.aspectRatio)) settings.aspectRatio = input.aspectRatio;\n if (clean(input.size)) settings.resolution = input.size;\n if (mode === 'image' && clean(input.quality)) settings.quality = input.quality;\n if (mode === 'video') {\n const seconds = Math.floor(Number(input.duration));\n if (Number.isFinite(seconds) && seconds > 0) settings.duration = seconds;\n if (typeof input.sound === 'boolean') settings.generateAudio = input.sound;\n }\n const references = Array.isArray(input.references) ? input.references.filter(Boolean).slice(0, 8) : [];\n const avoid = String(input.avoid || '').trim();\n const seed = Number.isInteger(input.seed) && input.seed >= 0 ? input.seed : undefined;\n const items = [];\n for (let index = 0; index < count; index++) {\n const item = { key: `${mode}-${index + 1}`, operation: mode, prompt };\n if (Object.keys(settings).length) item.settings = settings;\n if (mode === 'image' && references.length) item.references = references;\n if (mode === 'video' && input.startFrame) item.firstFrame = input.startFrame;\n if (mode === 'video' && input.startFrame && input.endFrame) item.lastFrame = input.endFrame;\n if (avoid) item.negativePrompt = avoid;\n if (seed !== undefined) item.seed = Math.min(2147483647, seed + index);\n items.push(item);\n }\n return items;\n}\n\n\n// Generate 1.2.0's one page (November 8, 2026, owner: \"rebuild the image and video generator block... it should be very\n// standard block, basically higgsfield in a block but for video and image with all options\"): a generator studio in\n// StillMade's own look. The choices sit on the left (Image or Video, the model card that opens every model as cards, the\n// prompt, the photos or frames the model takes, the camera move, every setting the model lists, how many, and More), and\n// what was made fills the stage on the right.\n// The stage is the SDK's (the same day, owner: \"it should have used the SDK that makes the image or video on the right sit\n// in something, like a canvas, with a download button at the top right\"): the newest result fills it, in StillMade's\n// player, with its reuse tools under it (use it as a photo, as the start or end frame, or put its prompt and settings back\n// on the left) and the earlier results as a strip below; before the first run it draws the place in the chosen shape, and\n// while a run works one placeholder per item. The status sits top-left and the actions top-right, as the kit lays them:\n// Download (StillMade saves what the stage shows) then Generate, rightmost, which stays put and becomes \"Generate image\n// again\". StillMade writes the price after Generate's words from what the page says the next run makes (StillMade.nextRun);\n// the page itself never shows a credit number.\n// As in 1.1.0 nothing about models is built in: they come from StillMade.models() when the page opens, and each model's\n// settings are data, so a model or setting StillMade adds later draws here and reaches the run with no new version.\n// Sharing: 1.1.0 kept its results with StillMade.updateShared, which turns StillMade's automatic sharing of the page's\n// controls off, so choices stopped syncing once a result existed. 1.2.0 binds every control itself (StillMade.bindShared,\n// under 1.1.0's field names, so a Step moving up keeps its choices) and keeps its own state in fields of its own: the\n// results with their prompt and settings, and the results picked as photos or frames.\n// generationItems, clean, otherSettings and cameraLine come from the module (src/main.js), which the build puts before\n// this file, so the price StillMade shows is for the same items the run asks for.\nconst $ = id => document.getElementById(id);\nconst root = $('root');\nconst KINDS = { image: 'image.generate', video: 'video.generate' };\n// How many results the page keeps with their prompt and settings, and how many clips on the strip play their own picture\n// (a frame view holds 24 playable copies; the clip on the stage takes one more).\nconst KEEP = 48, CLIP_THUMBS = 8;\n// The settings this page has inputs for, and the shared field each one's picks live in (the names 1.0.0 used).\n// Any other setting a model lists goes to the run in `settings` under its own key.\nconst INPUTS = { aspectRatio: 'aspectRatio', resolution: 'size', quality: 'quality', duration: 'duration', generateAudio: 'sound' };\nconst FIELDS = { aspectRatio: 'shape', resolution: 'size', quality: 'quality', duration: 'length', generateAudio: 'sound' };\n// The page's own controls and state: a setting by one of these names is not drawn twice, and one named like another\n// row's field gets a field of its own.\nconst OWN = new Set(['mode', 'count', 'imageModel', 'videoModel', 'prompt', 'avoid', 'seed', 'model', 'settings', 'references', 'startFrame', 'endFrame', 'camera', 'results', 'history', 'usePhotos', 'useStart', 'useEnd']);\nconst PICKS = ['usePhotos', 'useStart', 'useEnd'];\nconst field = key => FIELDS[key] || (Object.values(FIELDS).includes(key) ? `setting-${key}` : key);\nconst groups = () => [...document.querySelectorAll('[data-setting]')];\nconst groupFor = key => groups().find(group => group.dataset.setting === key);\nconst state = {\n input: {}, seenInput: null, busy: false, pending: [], started: false, drawn: '', camera: '',\n lists: { image: null, video: null }, failed: { image: false, video: false },\n shared: { state: {}, outputs: {}, made: [], canEdit: false }, sharedReady: false,\n picks: { usePhotos: [], useStart: null, useEnd: null }, writing: { usePhotos: 0, useStart: 0, useEnd: 0 },\n given: new Map(), binds: new Map(), fresh: [], images: new Map(), inputsDrawn: '',\n // The stage: the result picked on the strip (null: the newest), what the stage shows now, the strip's picks, and the clip\n // copies StillMade gave the page (they end when the Step's input changes: epoch).\n selected: null, shown: null, thumbs: new Map(), clipUrls: new Map(), epoch: 0, priced: '',\n};\n\nconst checked = name => { const el = document.querySelector(`input[name=\"${name}\"]:checked`); return el ? el.value : undefined; };\nconst visibleChecked = name => { const el = document.querySelector(`input[name=\"${name}\"]:checked`); return el && !el.disabled && !el.closest('label').hidden ? el.value : undefined; };\nconst announce = el => { el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); };\nfunction pick(name, value) {\n const el = [...document.querySelectorAll(`input[name=\"${name}\"]`)].find(option => option.value === String(value));\n if (!el || el.checked) return;\n el.checked = true;\n announce(el);\n}\nfunction setText(id, value) {\n const el = $(id), text = value === undefined || value === null ? '' : String(value);\n if (el.value === text) return;\n el.value = text;\n announce(el);\n}\nconst mode = () => checked('mode') === 'video' ? 'video' : 'image';\nconst listFor = kind => state.lists[kind];\nconst canEdit = () => !state.sharedReady || state.shared.canEdit !== false;\n// The chosen model of a mode, as StillMade listed it: [id, model], or [] before the list arrives or when it did not.\nfunction model(kind = mode()) {\n const list = listFor(kind);\n if (!list || !list.models.length) return [];\n const id = checked(`${kind}Model`), found = list.models.find(item => item.id === id) || list.models.find(item => item.id === list.default) || list.models[0];\n return [found.id, found];\n}\nconst message = error => (error && error.message) || 'That did not work. Try again.';\nfunction status(text, tone) { const el = $('status'); el.textContent = text || ''; if (tone) el.dataset.state = tone; else delete el.dataset.state; }\nconst plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;\n\n// Media values: the tuple StillMade issued ({kind, assetId, versionId, url}) is what a run is given and what is kept.\nconst isMedia = value => !!value && typeof value === 'object' && ['image', 'video'].includes(value.kind) && ['assetId', 'versionId', 'url'].every(key => typeof value[key] === 'string' && value[key]);\nconst keyOf = value => `${value.assetId}:${value.versionId}`;\nconst same = (a, b) => !!a && !!b && keyOf(a) === keyOf(b);\nconst tuple = value => ({ kind: value.kind, assetId: value.assetId, versionId: value.versionId, url: value.url });\n\n// Binding a control to its shared field. A group a screen fills later is bound once it has a choice; drawing it again\n// binds the new one in its place.\nfunction bind(name, id) {\n const node = $(id);\n if (!node || typeof StillMade.bindShared !== 'function') return;\n if (node.tagName === 'FIELDSET' && !node.querySelector('input[type=\"radio\"]')) return;\n const before = state.binds.get(name);\n if (before) { state.binds.delete(name); try { before(); } catch { /* already released */ } }\n try { state.binds.set(name, StillMade.bindShared(name, id)); } catch (error) { status(message(error), 'error'); }\n}\n\n// How a setting's value reads on its pick. Shapes get their frame; anything StillMade adds later reads as it is.\nconst words = value => typeof value === 'boolean' ? (value ? 'On' : 'Off') : /^[a-z]+$/.test(String(value)) ? String(value)[0].toUpperCase() + String(value).slice(1) : String(value);\nfunction valueLabel(key, value) {\n if (key === 'aspectRatio') return value === 'auto' ? 'Auto' : String(value);\n if (key === 'resolution') return String(value).replace(/k$/, 'K');\n if (key === 'duration') return `${value}s`;\n return words(value);\n}\nconst settingOf = (entry, key) => entry && Array.isArray(entry.settings) ? entry.settings.find(item => item.key === key) : undefined;\nconst valueOf = (setting, text) => setting.values.find(value => String(value) === text);\nconst ratioOf = shape => typeof shape === 'string' && /^\\d+(\\.\\d+)?:\\d+(\\.\\d+)?$/.test(shape) ? shape.replace(':', '/') : '';\n\n// One row per setting any model lists, holding every value any model takes; refresh() shows the chosen model's.\nfunction settingRows() {\n const rows = new Map();\n for (const kind of ['image', 'video']) for (const entry of (listFor(kind) || { models: [] }).models) for (const setting of entry.settings || []) {\n if (!setting || typeof setting.key !== 'string' || !/^[a-zA-Z][a-zA-Z0-9_]{0,40}$/.test(setting.key) || OWN.has(setting.key) || !Array.isArray(setting.values) || !setting.values.length) continue;\n const row = rows.get(setting.key) || { key: setting.key, label: setting.label || setting.key, values: [], most: 0 };\n for (const value of setting.values) if (['string', 'number', 'boolean'].includes(typeof value) && !row.values.some(seen => String(seen) === String(value))) row.values.push(value);\n row.most = Math.max(row.most, setting.values.length);\n rows.set(setting.key, row);\n }\n for (const row of rows.values()) if (row.values.every(value => typeof value === 'number')) row.values.sort((a, b) => a - b);\n return [...rows.values()];\n}\nfunction drawSettings() {\n const rows = settingRows(), signature = JSON.stringify(rows.map(row => [row.key, row.label, row.values]));\n if (signature === state.drawn) return;\n state.drawn = signature;\n const before = new Map(groups().map(group => [group.dataset.setting, checked(group.id)]));\n for (const group of groups()) group.remove();\n const anchor = $('count'), [, entry] = model();\n for (const row of rows) {\n const name = field(row.key), group = document.createElement('fieldset');\n // Many picks wrap as small chips (shapes, lengths); a few sit side by side.\n group.className = 'sm-choices gen-row';\n if (row.most <= 4) group.dataset.segmented = '';\n group.id = name; group.dataset.setting = row.key;\n // The picks carry the unit (\"5s\"), so a label's note in brackets (\"Length (seconds)\") is left off.\n group.append(Object.assign(document.createElement('legend'), { textContent: String(row.label).replace(/\\s*\\([^)]*\\)\\s*$/, '') || row.key }));\n // The pick to start from: the one shown before, else the chosen model's own default.\n const own = settingOf(entry, row.key), start = before.get(row.key) ?? (own && own.default !== undefined ? String(own.default) : undefined);\n for (const value of row.values) {\n const label = document.createElement('label'), radio = document.createElement('input');\n radio.type = 'radio'; radio.name = name; radio.value = String(value); radio.checked = start === String(value);\n label.append(radio);\n if (row.key === 'aspectRatio' && ratioOf(String(value))) {\n const frame = document.createElement('span'); frame.className = 'sm-shape'; frame.setAttribute('aria-hidden', 'true');\n frame.style.setProperty('--r', ratioOf(String(value)));\n label.append(frame);\n }\n label.append(Object.assign(document.createElement('span'), { textContent: valueLabel(row.key, value) }));\n group.append(label);\n }\n anchor.before(group);\n bind(name, name);\n }\n}\n\n// The words on a model: what the list says it takes. Prices stay StillMade's: a Block's screen shows no credit numbers\n// (readiness DOD-15), and StillMade confirms the exact total before anything is made.\nfunction tagsFor(kind, entry) {\n const size = settingOf(entry, 'resolution'), length = settingOf(entry, 'duration');\n const top = size && size.values.length ? `Up to ${valueLabel('resolution', size.values[size.values.length - 1])}` : '';\n if (kind === 'image') return [top, entry.references ? `Keeps ${plural(entry.references, 'photo')}` : 'No photos'].filter(Boolean);\n const longest = length ? Math.max(...length.values.filter(value => typeof value === 'number')) : -Infinity;\n return [longest > 0 ? `Up to ${longest}s` : '', top, entry.sound ? 'Sound' : '', entry.lastFrame ? 'End frame' : '', entry.needsFirstFrame ? 'Needs a start frame' : ''].filter(Boolean);\n}\nfunction fillTags(holder, tags) { holder.replaceChildren(...tags.map(tag => Object.assign(document.createElement('span'), { textContent: tag }))); }\n\n// The model picker for one mode, as cards from StillMade's list: its default is picked until someone picks another.\nfunction drawModels(kind) {\n const group = $(`${kind}Model`), name = group.id, list = listFor(kind), was = checked(name);\n for (const label of [...group.querySelectorAll('label')]) label.remove();\n for (const entry of list ? list.models : []) {\n const label = document.createElement('label'), radio = document.createElement('input'), tags = document.createElement('span');\n radio.type = 'radio'; radio.name = name; radio.value = entry.id; radio.checked = (was || list.default) === entry.id;\n tags.className = 'gen-tags'; fillTags(tags, tagsFor(kind, entry));\n label.append(radio, Object.assign(document.createElement('strong'), { textContent: entry.label }), tags, Object.assign(document.createElement('span'), { className: 'gen-why' }));\n group.append(label);\n }\n if (list && list.models.length && !group.querySelector('input:checked')) {\n const options = [...group.querySelectorAll('input')];\n (options.find(option => option.value === (list.default || list.models[0].id)) || options[0]).checked = true;\n }\n bind(name, name);\n}\n\n// One axis: show the values the model takes. When the person changed something here, a pick the model cannot make moves\n// to the model's own default; a change that arrived from someone else only shows what that model takes.\nfunction axis(group, setting, local) {\n const options = [...group.querySelectorAll('input')], allowed = setting ? setting.values.map(String) : [];\n for (const option of options) option.closest('label').hidden = !allowed.includes(option.value);\n group.dataset.off = String(!allowed.length);\n if (!allowed.length || !local || !canEdit()) return;\n const current = checked(group.id), fallback = setting.default !== undefined ? String(setting.default) : allowed[0];\n if (!allowed.includes(current)) pick(group.id, allowed.includes(fallback) ? fallback : allowed[0]);\n}\n\n// What goes in: the Step's own photos and frames, and results picked as photos or frames on this page.\nconst inputPhotos = () => (Array.isArray(state.input.references) ? state.input.references : []).filter(isMedia);\nconst pickedPhotos = () => state.picks.usePhotos.filter(item => isMedia(item) && !inputPhotos().some(photo => same(photo, item)));\nconst photos = () => [...inputPhotos(), ...pickedPhotos()];\nconst frame = slot => {\n const picked = state.picks[slot === 'start' ? 'useStart' : 'useEnd'], given = state.input[slot === 'start' ? 'startFrame' : 'endFrame'];\n return isMedia(picked) ? picked : isMedia(given) ? given : null;\n};\n\n\n// The run button's words: what it makes, and \"again\" once a result of that kind is on the stage, so making again is\n// pressed in the same place (StillMade adds the price after them).\nfunction runLabel(kind) {\n if (state.busy) return 'Generating…';\n const count = Number(checked('count')) || 1, noun = kind === 'video' ? 'video' : 'image', newest = resultList()[0];\n return `${count > 1 ? `Generate ${count} ${noun}s` : `Generate ${noun}`}${newest && newest.kind === kind ? ' again' : ''}`;\n}\n\nfunction refresh(local = false) {\n const kind = mode(), [id, entry] = model(), list = listFor(kind);\n root.dataset.mode = kind;\n root.dataset.models = list ? 'ready' : state.failed[kind] ? 'failed' : 'loading';\n $('prompt').placeholder = kind === 'video' ? 'Describe the shot: who or what, and how it moves' : 'Describe the picture';\n // The model card, or a few words while the list loads or when it did not: with no list StillMade runs its default.\n $('model-picker').dataset.off = String(!entry);\n if (!entry) $('model-picker').open = false;\n $('model-name').textContent = entry ? entry.label : '';\n fillTags($('model-tags'), entry ? [...tagsFor(kind, entry), ...(entry.available === false ? [entry.reason || 'Not available now'] : [])] : []);\n $('models-row').dataset.off = String(!!list);\n const note = $('models-status');\n if (state.failed[kind]) { note.textContent = 'Models did not load. StillMade picks the model.'; note.dataset.state = 'error'; }\n else if (!list) { note.textContent = 'Loading models'; note.dataset.state = 'loading'; }\n else { note.textContent = ''; delete note.dataset.state; }\n $('models-retry').hidden = !state.failed[kind];\n for (const group of groups()) axis(group, settingOf(entry, group.dataset.setting), local);\n state.camera = checked('camera') || '';\n // Inputs the chosen model takes: photos up to its limit (none, and it says so), a start frame (marked when it needs\n // one) and an end frame only for a model that takes one.\n const held = photos().length, limit = entry ? entry.references || 0 : 8;\n $('references-limit').textContent = !entry ? '' : limit ? `up to ${limit}` : 'This model keeps none';\n $('references').dataset.off = String(!!entry && !limit && !held);\n $('references-add').dataset.off = String(!!entry && held >= limit);\n const start = frame('start'), end = frame('end');\n $('start-needed').hidden = !(kind === 'video' && entry && entry.needsFirstFrame && !start);\n $('end-slot').dataset.off = String(!!entry && kind === 'video' && !entry.lastFrame);\n // StillMade's list does not say yet which models take a seed; a model that says it takes none hides it.\n $('seed-row').dataset.off = String(!!entry && entry.seed === false);\n // Models that cannot use what is already here are not offered, and StillMade says which ones it cannot run now.\n for (const kindName of ['image', 'video']) for (const option of document.querySelectorAll(`input[name=\"${kindName}Model\"]`)) {\n const item = (listFor(kindName) || { models: [] }).models.find(each => each.id === option.value);\n if (!item) continue;\n const reason = item.available === false ? (item.reason || 'Not available now')\n : kindName === 'image' ? (held > 0 && !item.references ? 'Does not keep your photos' : held > (item.references || 0) && item.references ? `Keeps up to ${plural(item.references, 'photo')}` : '')\n : item.needsFirstFrame && !start ? 'Needs a start frame' : end && !item.lastFrame ? 'Takes no end frame' : '';\n option.disabled = !!reason;\n // A reason its own words already say (\"Needs a start frame\") is not said twice.\n const label = option.closest('label');\n label.querySelector('.gen-why').textContent = [...label.querySelectorAll('.gen-tags > span')].some(tag => tag.textContent === reason) ? '' : reason;\n }\n $('generate').textContent = runLabel(kind);\n $('generate').disabled = state.busy || !canEdit();\n const shape = ratioOf(visibleChecked(field('aspectRatio')));\n $('place-shape').style.setProperty('--r', shape || (kind === 'video' ? '16/9' : '1/1'));\n $('place-glyph').dataset.glyph = kind;\n $('place-words').textContent = kind === 'video' ? 'Your video appears here' : 'Your picture appears here';\n // A result can be the end frame only for a video model that takes one.\n const [, videoModel] = model('video');\n for (const button of document.querySelectorAll('[data-use=\"end\"]')) {\n const off = !!videoModel && !videoModel.lastFrame;\n button.disabled = off;\n button.title = off ? `${videoModel.label} takes no end frame` : 'Use as the end frame';\n }\n offerPrice();\n return id;\n}\n\n\ndocument.addEventListener('change', event => { if (event.target.closest('fieldset, #more')) refresh(true); });\n// Camera: no move is picked until someone picks one, and pressing the picked one again takes it off.\n$('camera').addEventListener('click', event => {\n const option = event.target;\n if (option.tagName !== 'INPUT' || option.disabled || option.value !== state.camera) return;\n option.checked = false; state.camera = '';\n announce(option);\n});\n// The model card opens every model of this mode as cards; picking one, Escape or a press outside closes it.\nconst picker = $('model-picker');\n$('models').addEventListener('change', () => { picker.open = false; });\ndocument.addEventListener('keydown', event => { if (event.key === 'Escape' && picker.open) { picker.open = false; $('model-card').focus(); } });\ndocument.addEventListener('click', event => { if (picker.open && !picker.contains(event.target)) picker.open = false; });\n\n// The lists StillMade runs now, asked for once when the page has its input (StillMade answers only then).\nasync function loadModels(kinds) {\n for (const kind of kinds) state.failed[kind] = false;\n refresh();\n await Promise.all(kinds.map(async kind => {\n try {\n if (typeof StillMade.models !== 'function') throw new Error('This StillMade has no model list.');\n const list = await StillMade.models(KINDS[kind]);\n if (!list || !Array.isArray(list.models) || !list.models.length) throw new Error('The model list was empty.');\n state.lists[kind] = list;\n } catch { state.lists[kind] = null; state.failed[kind] = true; }\n }));\n for (const kind of kinds) drawModels(kind);\n drawSettings();\n refresh();\n applyGiven();\n}\n$('models-retry').addEventListener('click', () => { loadModels(['image', 'video'].filter(kind => state.failed[kind])); });\n\n// Choices that arrive as inputs (from Chat or an earlier Step) set the page. A value applies when it changes, and the\n// first time only where nobody has chosen yet, so opening the Step never undoes a choice made after its last run.\nfunction applyGiven() {\n if (!state.sharedReady || !canEdit()) return;\n const given = state.input, kept = state.shared.state || {};\n const offer = (name, value, ready, apply) => {\n if (value === undefined || value === null || value === '' || !ready) return;\n const text = JSON.stringify(value), seen = state.given.get(name);\n if (seen === text) return;\n state.given.set(name, text);\n if (seen === undefined && Object.hasOwn(kept, name)) return;\n apply(value);\n };\n offer('mode', ['image', 'video'].includes(given.mode) ? given.mode : undefined, true, value => pick('mode', value));\n offer('count', Number.isInteger(given.count) ? Math.max(1, Math.min(4, given.count)) : undefined, true, value => pick('count', value));\n offer('camera', typeof given.camera === 'string' ? given.camera : undefined, true, value => pick('camera', value));\n for (const kindName of ['image', 'video']) {\n const list = listFor(kindName), known = typeof given.model === 'string' && list && list.models.some(item => item.id === given.model);\n offer(`${kindName}Model`, known ? given.model : undefined, !!list, value => { pick('mode', kindName); pick(`${kindName}Model`, value); });\n }\n const named = { aspectRatio: given.aspectRatio, resolution: given.size, quality: given.quality, duration: Number.isInteger(given.duration) ? given.duration : undefined, generateAudio: typeof given.sound === 'boolean' ? given.sound : undefined };\n const others = given.settings && typeof given.settings === 'object' && !Array.isArray(given.settings) ? given.settings : {};\n for (const group of groups()) {\n const key = group.dataset.setting, value = named[key] !== undefined ? named[key] : others[key];\n offer(group.id, value, true, chosen => pick(group.id, String(chosen)));\n }\n giveText('prompt', state.input.prompt);\n giveText('avoid', state.input.avoid);\n giveText('seed', Number.isInteger(state.input.seed) && state.input.seed >= 0 ? String(state.input.seed) : undefined);\n refresh();\n}\n// The prompt, Leave out and Seed the Step was given fill their field while it is empty, once each time they change. A\n// field StillMade is still loading, or one this person may only view, is left to StillMade; a field fed by an earlier\n// Step (read only) just shows it.\nfunction giveText(id, value) {\n const el = $(id);\n if (typeof value !== 'string' || !value || el.value || el.disabled || state.given.get(`text:${id}`) === value) return;\n state.given.set(`text:${id}`, value);\n el.value = value;\n if (!el.readOnly) el.dispatchEvent(new Event('input', { bubbles: true }));\n}\n\n// Pictures the page shows small (photos and frames): one display copy each, kept while the page is open.\nfunction imagePreview(value, edge) {\n const key = `${keyOf(value)}@${edge || 512}`;\n if (!state.images.has(key)) {\n const shown = Promise.resolve(edge ? StillMade.previewImage(value, { edge }) : StillMade.previewImage(value)).then(result => typeof result === 'string' ? { url: result } : result);\n shown.catch(() => state.images.delete(key));\n state.images.set(key, shown);\n }\n return state.images.get(key);\n}\nconst glyph = (name, attribute = 'glyph', className = 'sm-glyph') => { const span = document.createElement('span'); span.className = className; span.dataset[attribute] = name; span.setAttribute('aria-hidden', 'true'); return span; };\nfunction iconButton(label, icon, data) {\n const button = document.createElement('button');\n button.type = 'button'; button.className = 'sm-icon'; button.title = label; button.setAttribute('aria-label', label);\n for (const [name, value] of Object.entries(data)) button.setAttribute(name, value);\n button.append(icon);\n return button;\n}\nconst off = label => { const tile = document.createElement('span'); tile.className = 'sm-media-off'; tile.setAttribute('role', 'img'); tile.setAttribute('aria-label', label); tile.title = label; tile.append(glyph('image-off')); return tile; };\nasync function smallTile(value, label, tools) {\n const li = document.createElement('li');\n try {\n const shown = await imagePreview(value);\n const img = document.createElement('img'); img.src = shown.url; img.alt = label; img.dataset.stillmadeExpand = 'off'; li.append(img);\n } catch { li.append(off('Could not show this one')); }\n if (tools.length) { const holder = document.createElement('span'); holder.className = 'sm-tile-tools'; holder.append(...tools); li.append(holder); }\n return li;\n}\nconst removeTool = data => iconButton('Remove', glyph('close'), data);\n\n// The photos and frames as tiles. A photo the Step holds is removed by StillMade; a result picked here is taken off here.\nasync function drawInputs() {\n const signature = JSON.stringify([inputPhotos().map(keyOf), pickedPhotos().map(keyOf), ['start', 'end'].map(slot => frame(slot) && keyOf(frame(slot))), !!isMedia(state.picks.useStart), !!isMedia(state.picks.useEnd)]);\n if (signature === state.inputsDrawn) return;\n state.inputsDrawn = signature;\n const list = $('references'), add = $('references-add'), tiles = [];\n inputPhotos().forEach((photo, index) => tiles.push(smallTile(photo, `Photo ${index + 1}`, [removeTool({ 'data-stillmade-remove': 'references', 'data-index': String(index) })])));\n pickedPhotos().forEach((photo, index) => tiles.push(smallTile(photo, `Photo ${inputPhotos().length + index + 1}`, [removeTool({ 'data-drop': 'usePhotos', 'data-key': keyOf(photo) })])));\n const drawn = await Promise.all(tiles);\n if (signature !== state.inputsDrawn) return;\n for (const li of [...list.children]) if (li !== add) li.remove();\n for (const li of drawn) list.insertBefore(li, add);\n for (const slot of ['start', 'end']) {\n const tile = $(`${slot}-tile`), adder = tile.querySelector('.gen-slot-add'), value = frame(slot), picked = isMedia(state.picks[slot === 'start' ? 'useStart' : 'useEnd']);\n for (const li of [...tile.children]) if (li !== adder) li.remove();\n adder.hidden = !!value;\n if (!value) continue;\n const port = slot === 'start' ? 'startFrame' : 'endFrame', name = slot === 'start' ? 'start frame' : 'end frame';\n const tools = [iconButton(`Replace the ${name}`, glyph('upload'), { 'data-stillmade-add': port })];\n if (picked) tools.push(removeTool({ 'data-drop': slot === 'start' ? 'useStart' : 'useEnd' }));\n const li = await smallTile(value, slot === 'start' ? 'Start frame' : 'End frame', tools);\n if (signature !== state.inputsDrawn) return;\n tile.insertBefore(li, adder);\n }\n refresh();\n}\n\n// Results picked as photos or frames are this page's own shared fields. The page shows a pick at once; a change from\n// someone else arrives through onShared.\nfunction setPick(name, value) {\n if (!canEdit()) return;\n state.picks[name] = value;\n state.writing[name]++;\n drawInputs().catch(() => {});\n refresh();\n Promise.resolve(StillMade.updateShared({ [name]: value })).catch(error => status(message(error), 'error')).finally(() => { state.writing[name]--; });\n}\ndocument.addEventListener('click', event => {\n const drop = event.target.closest('[data-drop]');\n if (!drop) return;\n const name = drop.dataset.drop;\n setPick(name, name === 'usePhotos' ? state.picks.usePhotos.filter(item => keyOf(item) !== drop.dataset.key) : null);\n});\n// A photo or frame that now sits in the Step (after a run, or a file someone added) is no longer a pick of its own.\nfunction settlePicks(previous) {\n if (!state.sharedReady || !canEdit()) return;\n const kept = state.picks.usePhotos.filter(item => isMedia(item) && !inputPhotos().some(photo => same(photo, item)));\n if (kept.length !== state.picks.usePhotos.length) setPick('usePhotos', kept);\n if (!previous) return;\n for (const [slot, port] of [['useStart', 'startFrame'], ['useEnd', 'endFrame']]) {\n const before = previous[port], now = state.input[port];\n if (state.picks[slot] && isMedia(now) && !(isMedia(before) && same(before, now))) setPick(slot, null);\n }\n}\n\n\nStillMade.onInput(async input => {\n const previous = state.seenInput;\n state.input = input || {};\n state.seenInput = state.input;\n // StillMade ends the clip copies it gave the page when the Step's input changes; the stage asks for them again.\n if (previous) { state.clipUrls.clear(); state.epoch++; }\n if (!state.started) { state.started = true; loadModels(['image', 'video']); }\n settlePicks(previous);\n applyGiven();\n try { await drawInputs(); } catch (error) { status(message(error), 'error'); }\n drawStage();\n});\n\n// What was made: newest first. The last run's outputs, then the results this page kept with their prompt and settings,\n// then anything else the Step made (a run Chat started, or one before this version).\nfunction keptResults() {\n const results = state.shared.state && state.shared.state.results;\n return results && typeof results === 'object' && Array.isArray(results.items) ? results.items.filter(isMedia) : [];\n}\nfunction resultList() {\n const list = [], seen = new Set(), meta = new Map();\n const legacy = Array.isArray(state.shared.state && state.shared.state.history) ? state.shared.state.history.filter(isMedia) : [];\n for (const item of legacy) meta.set(keyOf(item), { prompt: item.prompt, modelLabel: item.model });\n const ordered = [...keptResults()].sort((a, b) => (b.at || 0) - (a.at || 0) || (a.n || 0) - (b.n || 0));\n for (const item of [...state.fresh, ...ordered]) meta.set(keyOf(item), item);\n const outputs = state.shared.outputs && Array.isArray(state.shared.outputs.media) ? state.shared.outputs.media : [];\n const made = Array.isArray(state.shared.made) ? state.shared.made : [];\n for (const item of [...state.fresh, ...outputs, ...ordered, ...made, ...legacy]) {\n if (!isMedia(item) || seen.has(keyOf(item)) || list.length >= KEEP) continue;\n seen.add(keyOf(item));\n list.push({ ...tuple(item), ...(Number.isFinite(item.duration) ? { duration: item.duration } : {}), meta: meta.get(keyOf(item)) || null });\n }\n return list;\n}\n\nfunction caption(item) {\n const meta = item.meta || {}, settings = meta.settings || {};\n const shape = typeof settings.aspectRatio === 'string' && settings.aspectRatio !== 'auto' ? settings.aspectRatio : '';\n const length = item.kind === 'video' ? (Number.isFinite(settings.duration) ? `${settings.duration}s` : Number.isFinite(item.duration) ? `${Math.round(item.duration)}s` : '') : '';\n const text = [meta.modelLabel, shape, length].filter(Boolean).join(' · ');\n return text || (item.kind === 'video' ? 'Video' : 'Picture');\n}\nfunction ratioFor(item) {\n const shape = item.meta && item.meta.settings ? ratioOf(item.meta.settings.aspectRatio) : '';\n return shape || (item.kind === 'video' ? '16/9' : '1/1');\n}\n\n// A clip plays from a copy StillMade gives the page. The clip on the stage and the newest clips on the strip share one copy\n// each; older clips on the strip show a video glyph, and copies nobody shows are let go.\nfunction clipUrl(item) {\n const key = keyOf(item);\n if (!state.clipUrls.has(key)) {\n const url = Promise.resolve(StillMade.previewMedia(tuple(item))).then(shown => typeof shown === 'string' ? shown : shown && shown.url).catch(() => null);\n state.clipUrls.set(key, url);\n }\n return state.clipUrls.get(key);\n}\nfunction keepClips(keys) {\n for (const [key, url] of [...state.clipUrls]) if (!keys.has(key)) {\n state.clipUrls.delete(key);\n url.then(done => { if (done && typeof StillMade.releaseMedia === 'function') StillMade.releaseMedia(done); });\n }\n}\n\nfunction tileActions(item) {\n const actions = document.createElement('span'); actions.className = 'gen-acts';\n if (item.kind === 'image') actions.append(\n iconButton('Use as a photo', glyph('image'), { 'data-use': 'photo', 'data-stillmade-action': 'use-as-photo' }),\n iconButton('Use as the start frame', glyph('start', 'ico', 'gen-ico'), { 'data-use': 'start', 'data-stillmade-action': 'use-as-start-frame' }),\n iconButton('Use as the end frame', glyph('end', 'ico', 'gen-ico'), { 'data-use': 'end', 'data-stillmade-action': 'use-as-end-frame' }));\n if (item.meta && typeof item.meta.prompt === 'string') actions.append(iconButton('Reuse the prompt and settings', glyph('again', 'ico', 'gen-ico'), { 'data-use': 'again', 'data-stillmade-action': 'reuse' }));\n return actions;\n}\n\n// The shown result: a picture with StillMade's full-screen square, or a clip in StillMade's player, with what made it and\n// its reuse tools under it.\nfunction showFigure(item) {\n const figure = document.createElement('figure'), foot = document.createElement('figcaption'), words = document.createElement('small');\n figure.className = 'gen-figure'; figure.dataset.key = keyOf(item); figure.style.setProperty('--ratio', ratioFor(item));\n foot.className = 'gen-foot';\n words.className = 'gen-cap'; words.textContent = caption(item); words.title = (item.meta && item.meta.prompt) || '';\n foot.append(words, tileActions(item));\n figure.append(foot);\n const place = media => figure.insertBefore(media, foot);\n if (item.kind === 'video') clipUrl(item).then(url => {\n if (!url) { place(off('Could not show this one')); return; }\n const video = document.createElement('video');\n video.controls = true; video.playsInline = true; video.preload = 'metadata'; video.src = url;\n video.setAttribute('aria-label', caption(item));\n place(video);\n });\n else imagePreview(tuple(item), 1024).then(shown => {\n const img = document.createElement('img');\n img.src = shown.url; img.alt = (item.meta && item.meta.prompt) || 'Generated picture'; img.dataset.stillmadeExpand = 'made';\n if (shown.width && shown.height) { img.width = shown.width; img.height = shown.height; }\n place(img);\n }, () => place(off('Could not show this one')));\n return figure;\n}\n\n// Placeholders while a run works: one per item, in the shape that is coming.\nfunction waiting() {\n const row = document.createElement('div'); row.className = 'gen-waits';\n for (const spec of state.pending) {\n const tile = document.createElement('div');\n // Its width follows the shape, so a tall shape stays tall: the ratio as a number for the width, as written for the shape.\n const [w, h] = spec.ratio.split('/').map(Number);\n tile.className = 'gen-wait'; tile.style.setProperty('--ratio', spec.ratio); tile.style.setProperty('--ratio-n', String(w > 0 && h > 0 ? w / h : 1));\n tile.append(Object.assign(document.createElement('span'), { className: 'gen-pulse' }));\n row.append(tile);\n }\n return row;\n}\n\n// One pick on the strip: the result's own picture, its clip's first frame, or the video glyph for an older clip.\nfunction stripPick(item, clip) {\n const li = document.createElement('li'), button = document.createElement('button');\n button.type = 'button'; button.dataset.key = keyOf(item); button.dataset.stillmadeAction = 'show';\n button.setAttribute('aria-label', `Show ${caption(item)}`); button.title = caption(item);\n li.append(button);\n if (item.kind === 'image') imagePreview(tuple(item)).then(shown => {\n const img = document.createElement('img'); img.src = shown.url; img.alt = ''; img.dataset.stillmadeExpand = 'off'; button.append(img);\n }, () => button.append(off('Could not show this one')));\n else if (clip) clipUrl(item).then(url => {\n if (!url) { button.append(glyph('video')); return; }\n const video = document.createElement('video'); video.muted = true; video.playsInline = true; video.preload = 'metadata'; video.src = url; video.setAttribute('aria-hidden', 'true');\n button.append(video);\n });\n else button.append(glyph('video'));\n return li;\n}\n\nfunction drawStage() {\n const list = resultList(), pending = state.pending.length > 0;\n const item = pending ? null : list.find(each => keyOf(each) === state.selected) || list[0] || null;\n const shown = $('shown'), place = $('place');\n // What the stage shows: the place in the coming shape, one placeholder per item while a run works, or the result.\n const signature = pending ? `wait:${JSON.stringify(state.pending)}` : item ? `${keyOf(item)}:${state.epoch}:${JSON.stringify(item.meta)}` : 'place';\n if (state.shown !== signature) {\n state.shown = signature;\n for (const node of [...shown.children]) if (node !== place) node.remove();\n place.hidden = pending || !!item;\n if (pending) shown.append(waiting());\n else if (item) shown.append(showFigure(item));\n }\n $('download').hidden = !item;\n // The strip: every result, newest first, the shown one marked; with one result there is nothing to pick.\n const strip = $('strip'), videos = list.filter(each => each.kind === 'video').slice(0, CLIP_THUMBS).map(keyOf), clips = new Set(videos);\n if (item && item.kind === 'video') clips.add(keyOf(item));\n keepClips(clips);\n const wanted = new Set(list.map(keyOf));\n for (const [key] of [...state.thumbs]) if (!wanted.has(key)) { state.thumbs.get(key).li.remove(); state.thumbs.delete(key); }\n const nodes = list.map(each => {\n const key = keyOf(each), clip = clips.has(key), held = state.thumbs.get(key), mark = `${clip}:${state.epoch}:${caption(each)}`;\n if (!held || held.mark !== mark) { if (held) held.li.remove(); state.thumbs.set(key, { li: stripPick(each, clip), mark }); }\n const li = state.thumbs.get(key).li;\n li.firstChild.setAttribute('aria-current', String(!!item && keyOf(item) === key));\n return li;\n });\n nodes.forEach((node, index) => { if (strip.children[index] !== node) strip.insertBefore(node, strip.children[index] || null); });\n strip.hidden = list.length < 2;\n refresh();\n}\n$('strip').addEventListener('click', event => {\n const button = event.target.closest('button[data-key]');\n if (!button) return;\n state.selected = button.dataset.key;\n drawStage();\n});\n\n// After a run: its results join the kept list with the prompt, model and settings that made them. Several people may\n// add at once, so the list is a keyed collection StillMade merges, and the oldest beyond 48 leave it.\nasync function remember(made, meta) {\n const now = Date.now(), fresh = made.filter(isMedia).map((item, n) => ({ id: keyOf(item), ...tuple(item), ...(Number.isFinite(item.duration) ? { duration: item.duration } : {}), at: now, n, ...meta, ...(Number.isInteger(meta.seed) ? { seed: Math.min(2147483647, meta.seed + n) } : {}) }));\n if (!fresh.length) return;\n state.fresh = [...fresh, ...state.fresh.filter(item => !fresh.some(other => other.id === item.id))].slice(0, KEEP);\n state.selected = null;\n drawStage();\n if (!canEdit()) return;\n for (let attempt = 0; attempt < 2; attempt++) {\n const shared = (typeof StillMade.getShared === 'function' ? StillMade.getShared() : state.shared).state || {};\n const has = Object.hasOwn(shared, 'results'), current = has ? shared.results : undefined;\n const mergeable = has && !!current && typeof current === 'object' && Array.isArray(current.items);\n const items = [...(mergeable ? current.items : []).filter(item => item && !fresh.some(other => other.id === item.id)), ...fresh];\n while (items.length > KEEP || JSON.stringify(items).length > 240000) items.splice(items.reduce((oldest, item, index) => (item.at || 0) < (items[oldest].at || 0) ? index : oldest, 0), 1);\n try {\n await StillMade.updateShared({ results: { items } }, { results: has ? { exists: true, value: current } : { exists: false } }, mergeable ? { merge: true } : undefined);\n state.fresh = state.fresh.filter(item => !fresh.some(other => other.id === item.id));\n return;\n } catch (error) { if (attempt) status(message(error), 'error'); else await new Promise(resolve => setTimeout(resolve, 300)); }\n }\n}\n\nStillMade.onShared(shared => {\n state.shared = { state: {}, outputs: {}, made: [], ...(shared || {}) };\n const first = !state.sharedReady;\n state.sharedReady = true;\n const kept = state.shared.state || {};\n for (const name of PICKS) if (!state.writing[name]) state.picks[name] = name === 'usePhotos' ? (Array.isArray(kept.usePhotos) ? kept.usePhotos.filter(isMedia) : []) : isMedia(kept[name]) ? kept[name] : null;\n if (first) settlePicks(null);\n applyGiven();\n drawInputs().catch(() => {});\n drawStage();\n // A pick someone else made arrives without a change event: show what their model takes.\n queueMicrotask(() => refresh());\n});\n\n// A result's actions: as a photo, as the start or end frame, or its prompt, model and settings back on the left.\nfunction usePhoto(item) {\n pick('mode', 'image'); refresh(true);\n const [, entry] = model('image'), held = photos();\n if (held.some(photo => same(photo, item))) return;\n if (entry && !entry.references) { status(`${entry.label} keeps no photos.`, 'error'); return; }\n if (entry && held.length >= entry.references) { status(`${entry.label} keeps up to ${plural(entry.references, 'photo')}.`, 'error'); return; }\n setPick('usePhotos', [...state.picks.usePhotos.filter(isMedia), tuple(item)]);\n status('Added to your photos', 'success');\n}\nfunction useFrame(slot, item) {\n pick('mode', 'video'); refresh(true);\n setPick(slot === 'start' ? 'useStart' : 'useEnd', tuple(item));\n status(slot === 'start' ? 'Set as the start frame' : 'Set as the end frame', 'success');\n}\nfunction reuse(item) {\n const meta = item.meta || {}, kind = (meta.mode || item.kind) === 'video' ? 'video' : 'image', list = listFor(kind);\n pick('mode', kind);\n const known = list && list.models.find(entry => entry.id === meta.model);\n if (list) pick(`${kind}Model`, known ? known.id : list.default || list.models[0].id);\n refresh(true);\n for (const [key, value] of Object.entries(meta.settings || {})) { const group = groupFor(key); if (group) pick(group.id, String(value)); }\n setText('prompt', meta.prompt || '');\n setText('avoid', meta.avoid || '');\n setText('seed', Number.isInteger(meta.seed) ? meta.seed : '');\n const camera = [...document.querySelectorAll('input[name=\"camera\"]')], wanted = camera.find(option => option.value === meta.camera);\n if (wanted) pick('camera', wanted.value);\n else { const on = camera.find(option => option.checked); if (on) { on.checked = false; announce(on); } }\n refresh(true);\n status('Prompt and settings are back', 'success');\n}\n$('shown').addEventListener('click', event => {\n const button = event.target.closest('[data-use]');\n if (!button || button.disabled || !canEdit() || state.busy) return;\n const key = button.closest('.gen-figure').dataset.key, item = resultList().find(each => keyOf(each) === key);\n if (!item) return;\n const use = button.dataset.use;\n if (use === 'photo') usePhoto(item);\n else if (use === 'start' || use === 'end') useFrame(use, item);\n else if (use === 'again') reuse(item);\n});\n\nfunction busy(on) {\n state.busy = on;\n root.toggleAttribute('aria-busy', on);\n refresh();\n}\n\n// A value the page clears is sent empty: a run takes the Step's last value for anything left out, so a model, setting,\n// camera move or seed taken off here would otherwise come back from the run before.\nfunction clearing(values, name, empty) {\n const before = state.input[name];\n if (Object.hasOwn(values, name) || before === undefined || before === null || before === '' || (typeof before === 'object' && !Object.keys(before).length)) return;\n values[name] = empty;\n}\n\n// What a press of Generate would run with: the choices on the page as the Step's inputs.\nfunction runValues() {\n const kind = mode(), [id, entry] = model(), list = listFor(kind), prompt = $('prompt').value.trim();\n const count = Number(checked('count')) || 1;\n const values = { prompt, mode: kind, count };\n // The model goes only when someone picked one other than StillMade's default; with none named StillMade runs its\n // default, so a model StillMade makes its default later is the one used.\n if (entry && list && id !== list.default) values.model = id;\n const others = {}, settings = {};\n for (const setting of entry ? entry.settings || [] : []) {\n const group = groupFor(setting.key), picked = group ? visibleChecked(group.id) : undefined, value = picked === undefined ? undefined : valueOf(setting, picked);\n if (value === undefined) continue;\n settings[setting.key] = value;\n if (INPUTS[setting.key]) values[INPUTS[setting.key]] = value; else others[setting.key] = value;\n }\n if (Object.keys(others).length) values.settings = others;\n const held = photos(), start = frame('start'), end = frame('end');\n if (kind === 'image' && held.length && (!entry || entry.references)) values.references = held.slice(0, entry ? entry.references : 8);\n if (kind === 'video') {\n if (start) values.startFrame = start;\n if (start && end && (!entry || entry.lastFrame)) values.endFrame = end;\n }\n const camera = kind === 'video' ? checked('camera') || '' : '';\n if (camera) values.camera = camera;\n // A camera move the Step holds and this page shows is taken off when no chip is picked.\n else if ([...document.querySelectorAll('input[name=\"camera\"]')].some(option => option.value === state.input.camera)) values.camera = '';\n const avoid = $('avoid').value.trim();\n if (avoid) values.avoid = avoid;\n const seed = Number($('seed').value);\n if ((!entry || entry.seed !== false) && $('seed').value !== '' && Number.isInteger(seed) && seed >= 0) values.seed = seed;\n if (entry) for (const [name, empty] of [['model', ''], ['aspectRatio', ''], ['size', ''], ['quality', ''], ['settings', {}]]) clearing(values, name, empty);\n clearing(values, 'avoid', '');\n // A seed taken off: StillMade's integer input cannot be empty, and the run reads a negative seed as none.\n clearing(values, 'seed', -1);\n return { values, kind, id, entry, list, settings, camera, avoid, prompt, count, start };\n}\n\n// What the next press makes, for the price StillMade writes after Generate's words: the module's own items for these\n// choices, without the words (the price never reads them). Said again only when it changes.\nfunction offerPrice() {\n if (typeof StillMade.nextRun !== 'function') return;\n let plan = null;\n try { plan = { items: generationItems({ ...runValues().values, prompt: 'x' }).map(({ prompt, ...item }) => item) }; } catch { plan = null; }\n const text = JSON.stringify(plan);\n if (text === state.priced) return;\n state.priced = text;\n try { StillMade.nextRun(plan); } catch { state.priced = ''; }\n}\n\nasync function generate() {\n if (state.busy || !canEdit()) return;\n const { values, kind, id, entry, list, settings, camera, avoid, prompt, count, start } = runValues();\n if (!prompt) { status(kind === 'video' ? 'Describe the shot first.' : 'Describe the picture first.', 'error'); $('prompt').focus(); return; }\n if (kind === 'video' && entry && entry.needsFirstFrame && !start) { status(`${entry.label} needs a start frame.`, 'error'); return; }\n const label = entry ? entry.label : kind === 'video' ? 'Video' : 'Picture', shape = ratioOf(values.aspectRatio) || (kind === 'video' ? '16/9' : '1/1');\n state.pending = Array.from({ length: count }, () => ({ ratio: shape, label, count }));\n busy(true);\n drawStage();\n status(count > 1 ? `Generating ${count} ${kind}s…` : `Generating the ${kind === 'video' ? 'video' : 'picture'}…`, 'loading');\n try {\n const outputs = await StillMade.run(values);\n const made = outputs && Array.isArray(outputs.media) ? outputs.media : [];\n state.pending = [];\n await remember(made, { prompt, mode: kind, model: entry && list && id !== list.default ? id : '', modelLabel: entry ? entry.label : '', settings, camera, avoid, ...(values.seed !== undefined && values.seed >= 0 ? { seed: values.seed } : {}) });\n const missing = outputs && typeof outputs.notMade === 'string' ? outputs.notMade.split(':')[0] : '';\n status(missing || (made.length ? `${made.length} made` : ''), missing ? 'error' : made.length ? 'success' : undefined);\n } catch (error) { status(message(error), 'error'); }\n finally { state.pending = []; busy(false); drawStage(); }\n}\n$('generate').addEventListener('click', generate);\n\n// Every control is shared under its field, as in 1.1.0; the setting rows and model cards are bound once they are drawn.\nfor (const name of ['mode', 'count', 'camera', 'seed', 'avoid', 'prompt']) bind(name, name);\ndrawStage();\n\n})();\n"
322
+ },
323
+ "tests": [
324
+ {
325
+ "name": "Makes two pictures that keep your photo",
326
+ "input": {
327
+ "prompt": "The bottle on a wet rock at dawn",
328
+ "mode": "image",
329
+ "aspectRatio": "4:5",
330
+ "size": "2K",
331
+ "count": 2,
332
+ "references": [
333
+ {
334
+ "kind": "image",
335
+ "assetId": "photo-asset",
336
+ "versionId": "photo-v1",
337
+ "url": "/api/media/example/photo-1.jpg",
338
+ "mimeType": "image/jpeg",
339
+ "width": 1600,
340
+ "height": 1200
341
+ }
342
+ ]
343
+ },
344
+ "mocks": {
345
+ "actions": {
346
+ "media.generate": {
347
+ "value": [
348
+ {
349
+ "key": "image-1",
350
+ "kind": "image",
351
+ "assetId": "made-image-1",
352
+ "versionId": "made-image-1-v1",
353
+ "url": "/api/media/example/photo-2.jpg",
354
+ "mimeType": "image/jpeg",
355
+ "width": 1200,
356
+ "height": 1600
357
+ },
358
+ {
359
+ "key": "image-2",
360
+ "kind": "image",
361
+ "assetId": "made-image-2",
362
+ "versionId": "made-image-2-v1",
363
+ "url": "/api/media/example/photo-4.jpg",
364
+ "mimeType": "image/jpeg",
365
+ "width": 1600,
366
+ "height": 1200
367
+ }
368
+ ],
369
+ "failures": []
370
+ }
371
+ }
372
+ },
373
+ "expected": {
374
+ "media": [
375
+ {
376
+ "kind": "image",
377
+ "assetId": "made-image-1",
378
+ "versionId": "made-image-1-v1",
379
+ "url": "/api/media/example/photo-2.jpg",
380
+ "mimeType": "image/jpeg",
381
+ "width": 1200,
382
+ "height": 1600
383
+ },
384
+ {
385
+ "kind": "image",
386
+ "assetId": "made-image-2",
387
+ "versionId": "made-image-2-v1",
388
+ "url": "/api/media/example/photo-4.jpg",
389
+ "mimeType": "image/jpeg",
390
+ "width": 1600,
391
+ "height": 1200
392
+ }
393
+ ]
394
+ }
395
+ },
396
+ {
397
+ "name": "Makes a vertical clip from a start and end frame with its own sound",
398
+ "input": {
399
+ "prompt": "Slow push in as the door opens",
400
+ "mode": "video",
401
+ "aspectRatio": "9:16",
402
+ "size": "720p",
403
+ "duration": 6,
404
+ "sound": true,
405
+ "startFrame": {
406
+ "kind": "image",
407
+ "assetId": "photo-asset",
408
+ "versionId": "photo-v1",
409
+ "url": "/api/media/example/photo-1.jpg",
410
+ "mimeType": "image/jpeg",
411
+ "width": 1600,
412
+ "height": 1200
413
+ },
414
+ "endFrame": {
415
+ "kind": "image",
416
+ "assetId": "frame-asset",
417
+ "versionId": "frame-v1",
418
+ "url": "/api/media/example/photo-3.jpg",
419
+ "mimeType": "image/jpeg",
420
+ "width": 1600,
421
+ "height": 1200
422
+ },
423
+ "avoid": "blur",
424
+ "seed": 7
425
+ },
426
+ "mocks": {
427
+ "actions": {
428
+ "media.generate": {
429
+ "value": [
430
+ {
431
+ "key": "video-1",
432
+ "kind": "video",
433
+ "assetId": "made-video-1",
434
+ "versionId": "made-video-1-v1",
435
+ "url": "/api/media/example/clip.mp4",
436
+ "mimeType": "video/mp4",
437
+ "width": 640,
438
+ "height": 360,
439
+ "duration": 2
440
+ }
441
+ ],
442
+ "failures": []
443
+ }
444
+ }
445
+ },
446
+ "expected": {
447
+ "media": [
448
+ {
449
+ "kind": "video",
450
+ "assetId": "made-video-1",
451
+ "versionId": "made-video-1-v1",
452
+ "url": "/api/media/example/clip.mp4",
453
+ "mimeType": "video/mp4",
454
+ "width": 640,
455
+ "height": 360,
456
+ "duration": 2
457
+ }
458
+ ]
459
+ }
460
+ },
461
+ {
462
+ "name": "Passes a setting it has no input for to StillMade",
463
+ "input": {
464
+ "prompt": "A paper lantern over a dark lake",
465
+ "mode": "image",
466
+ "settings": {
467
+ "quality": "high"
468
+ }
469
+ },
470
+ "mocks": {
471
+ "actions": {
472
+ "media.generate": {
473
+ "value": [
474
+ {
475
+ "key": "image-1",
476
+ "kind": "image",
477
+ "assetId": "made-image-1",
478
+ "versionId": "made-image-1-v1",
479
+ "url": "/api/media/example/photo-2.jpg",
480
+ "mimeType": "image/jpeg",
481
+ "width": 1200,
482
+ "height": 1600
483
+ }
484
+ ],
485
+ "failures": []
486
+ }
487
+ }
488
+ },
489
+ "expected": {
490
+ "media": [
491
+ {
492
+ "kind": "image",
493
+ "assetId": "made-image-1",
494
+ "versionId": "made-image-1-v1",
495
+ "url": "/api/media/example/photo-2.jpg",
496
+ "mimeType": "image/jpeg",
497
+ "width": 1200,
498
+ "height": 1600
499
+ }
500
+ ]
501
+ }
502
+ },
503
+ {
504
+ "name": "Says the camera move at the end of a clip's prompt",
505
+ "input": {
506
+ "prompt": "A cup of coffee on a wooden table",
507
+ "mode": "video",
508
+ "camera": "slow push in",
509
+ "startFrame": {
510
+ "kind": "image",
511
+ "assetId": "photo-asset",
512
+ "versionId": "photo-v1",
513
+ "url": "/api/media/example/photo-1.jpg",
514
+ "mimeType": "image/jpeg",
515
+ "width": 1600,
516
+ "height": 1200
517
+ }
518
+ },
519
+ "mocks": {
520
+ "actions": {
521
+ "media.generate": {
522
+ "value": [
523
+ {
524
+ "key": "video-1",
525
+ "kind": "video",
526
+ "assetId": "made-video-1",
527
+ "versionId": "made-video-1-v1",
528
+ "url": "/api/media/example/clip.mp4",
529
+ "mimeType": "video/mp4",
530
+ "width": 640,
531
+ "height": 360,
532
+ "duration": 2
533
+ }
534
+ ],
535
+ "failures": []
536
+ }
537
+ }
538
+ },
539
+ "expected": {
540
+ "media": [
541
+ {
542
+ "kind": "video",
543
+ "assetId": "made-video-1",
544
+ "versionId": "made-video-1-v1",
545
+ "url": "/api/media/example/clip.mp4",
546
+ "mimeType": "video/mp4",
547
+ "width": 640,
548
+ "height": 360,
549
+ "duration": 2
550
+ }
551
+ ]
552
+ }
553
+ }
554
+ ]
555
+ };