@vidofy/mcp 0.1.0

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.
@@ -0,0 +1,869 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Vidofy generation</title>
6
+ <style>
7
+ /* Host tokens with fallbacks. The host sends its palette in
8
+ hostContext.styles.variables; a host that sends none must still render, so
9
+ every variable is declared here first and only overwritten if one arrives. */
10
+ :root{
11
+ --color-background-primary:#fff;
12
+ --color-background-secondary:#f6f6f5;
13
+ --color-border:#e6e4e1;
14
+ --color-text-primary:#141413;
15
+ --color-text-secondary:#6b6a67;
16
+ --color-accent:#d97757;
17
+ --font-sans:ui-sans-serif,-apple-system,"Segoe UI",system-ui,sans-serif;
18
+ }
19
+ @media (prefers-color-scheme:dark){
20
+ :root{
21
+ --color-background-primary:#1f1e1d;
22
+ --color-background-secondary:#262625;
23
+ --color-border:#3a3937;
24
+ --color-text-primary:#f5f4f2;
25
+ --color-text-secondary:#a3a09b;
26
+ }
27
+ }
28
+ *{box-sizing:border-box}
29
+ html,body{margin:0;padding:0;background:transparent}
30
+ body{font-family:var(--font-sans);color:var(--color-text-primary);font-size:13px}
31
+
32
+ .card{border:1px solid var(--color-border);border-radius:12px;overflow:hidden;
33
+ background:var(--color-background-primary)}
34
+ .head{display:flex;align-items:center;gap:8px;padding:10px 12px;
35
+ border-bottom:1px solid var(--color-border)}
36
+ /* The provider's logo. Falls back to a neutral mark when the model has no
37
+ * icon or the image fails to load — a coloured square that pretends to be a
38
+ * brand is worse than an obvious placeholder. */
39
+ .mark{width:18px;height:18px;border-radius:5px;flex:none;object-fit:contain;
40
+ background:var(--color-background-secondary)}
41
+ .title{font-size:12.5px;font-weight:600;flex:1;min-width:0;
42
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
43
+ .pill{font-size:11px;padding:2px 7px;border-radius:999px;flex:none;
44
+ background:var(--color-background-secondary);color:var(--color-text-secondary)}
45
+ .pill.live{color:var(--color-accent)}
46
+ .pill.bad{color:#dc2626}
47
+
48
+ /* A stage of FIXED height, with the media contained inside it.
49
+ *
50
+ * The card used to take the media's own height, so a 9:16 video produced a very
51
+ * tall card and a 16:9 image a short one — the same tool giving a different
52
+ * shape every time, and the whole conversation below it reflowing when the
53
+ * result replaced the wait. A fixed stage ends both: the box is the same before
54
+ * and after, the media fits itself inside by its own ratio, and what is left
55
+ * over is simply empty.
56
+ *
57
+ * Dark in both themes on purpose. This is the one part of the card that is not
58
+ * chrome — it is where the picture is looked at — and neutral dark is what
59
+ * every media viewer uses, because it stops the surround competing with the
60
+ * image. The rest of the card still follows the host's palette.
61
+ *
62
+ * 16:9, not a fixed pixel height. A fixed 380px looked right in a narrow test
63
+ * harness and is wrong where the card actually lives: on a 1350px pane a
64
+ * landscape 16:9 image would be limited by that height to 675px wide and sit
65
+ * between two 337px bars — the most common shape there is, displayed at half
66
+ * width. Matching the stage to 16:9 makes that shape fill the width exactly,
67
+ * and leaves portrait to letterbox at the sides, which is the trade every media
68
+ * viewer makes because landscape is what most output is.
69
+ *
70
+ * It is still ONE height for every generation at a given pane width, which is
71
+ * the property that mattered: the box does not change between the wait and the
72
+ * result, and nothing below it moves.
73
+ *
74
+ * NO invented ceiling. A max-height of 560px was tried and quietly undid the
75
+ * whole point: on a 1350px pane it capped the stage before 16:9 was reached, so
76
+ * a landscape image came out 996 wide between two 177px bars — exactly the
77
+ * problem this change exists to remove, reintroduced by a number chosen without
78
+ * measuring. The only ceiling honoured is the host's own
79
+ * containerDimensions.maxHeight, applied at handshake below: the host knows how
80
+ * tall its pane is, and this does not. */
81
+ .stage{position:relative;width:100%;aspect-ratio:16/9;background:#18181b;
82
+ display:flex;align-items:center;justify-content:center;overflow:hidden}
83
+ .stage img{display:block;max-width:100%;max-height:100%;width:auto;height:auto;
84
+ object-fit:contain}
85
+
86
+ /* The wait fills the same stage the result will. It was aspect-ratio:3/4 —
87
+ * a portrait-shaped hole reserved before anything knew the shape — and briefly
88
+ * a short strip, which fixed the size but made the card jump when the image
89
+ * arrived. Filling the stage means the box never changes at all. */
90
+ .wait{width:100%;height:100%;display:flex;flex-direction:column;
91
+ align-items:center;justify-content:center;gap:7px;position:relative;
92
+ color:#e4e4e7}
93
+ .wait::after{content:"";position:absolute;inset:0;
94
+ background:linear-gradient(100deg,transparent 35%,rgba(127,127,127,.11) 50%,transparent 65%);
95
+ background-size:220% 100%;animation:sweep 1.9s linear infinite}
96
+ @keyframes sweep{from{background-position:180% 0}to{background-position:-80% 0}}
97
+ @media (prefers-reduced-motion:reduce){.wait::after{animation:none}}
98
+ .pct{font-size:19px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.02em}
99
+
100
+ /* A turning ring, shown while there is no percentage to show.
101
+ *
102
+ * The dash that stood here read as "nothing is happening" — a still character
103
+ * in a still box, which is what a stalled card would look like too. Motion is
104
+ * the only thing that distinguishes waiting from stuck, so the ring runs
105
+ * whenever the server has given us no number, and gives way the moment it does.
106
+ * Honoured reduced-motion: the ring stops turning and simply sits there, which
107
+ * is still a shape rather than a dash. */
108
+ .spin{width:26px;height:26px;border-radius:50%;
109
+ border:2.5px solid rgba(255,255,255,.16);border-top-color:var(--color-accent);
110
+ animation:turn .9s linear infinite}
111
+ @keyframes turn{to{transform:rotate(360deg)}}
112
+ @media (prefers-reduced-motion:reduce){.spin{animation:none}}
113
+ /* Fixed colours, not tokens: these sit ON the dark stage, so the host's
114
+ text colour — which is dark in light mode — would be unreadable there. */
115
+ .wait-note{font-size:11.5px;color:#a1a1aa}
116
+ .stage-note{font-size:12px;color:#a1a1aa;padding:0 20px;text-align:center;line-height:1.6}
117
+ .bar{position:absolute;left:0;right:0;bottom:0;height:2px;background:rgba(127,127,127,.16);
118
+ overflow:hidden}
119
+ .bar>i{display:block;height:100%;width:0;background:var(--color-accent);transition:width .6s ease}
120
+ /* Indeterminate: a short segment crossing the track, for when there is no
121
+ percentage to fill it with (which is now always — see cardState). A 0-width
122
+ determinate bar is an empty line that reads as "stalled"; this reads as
123
+ "running" without asserting how far along it is. Reduced motion parks a
124
+ quarter-width segment at the left: still a mark, just not a moving one. */
125
+ .bar.indet>i{width:32%;transition:none;animation:slide 1.6s ease-in-out infinite}
126
+ @keyframes slide{from{transform:translateX(-110%)}to{transform:translateX(320%)}}
127
+ @media (prefers-reduced-motion:reduce){.bar.indet>i{animation:none;width:25%}}
128
+
129
+ /* A real control since 2026-09-11, not the decoration it was.
130
+ *
131
+ * It carried `pointer-events:none` and had no handler, so the one thing on the
132
+ * card that looks like a play button was the one thing that did nothing — the
133
+ * owner clicked it and reported it broken, correctly. It now plays the video in
134
+ * place; `pointer-events:auto` is what makes the click reach it at all. */
135
+ .play{position:absolute;width:46px;height:46px;border-radius:50%;
136
+ background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;
137
+ pointer-events:auto;cursor:pointer;border:0;padding:0;
138
+ transition:background .15s ease,transform .15s ease}
139
+ .play:hover{background:rgba(0,0,0,.72);transform:scale(1.06)}
140
+ .play:focus-visible{outline:2px solid var(--color-accent);outline-offset:3px}
141
+ @media (prefers-reduced-motion:reduce){.play{transition:none}.play:hover{transform:none}}
142
+ .play::after{content:"";border-left:13px solid #fff;border-top:8px solid transparent;
143
+ border-bottom:8px solid transparent;margin-left:4px}
144
+ /* The poster is the rest of the target: on a video the whole frame is the
145
+ click area, which is what every player trains people to expect. */
146
+ .stage img.tap{cursor:pointer}
147
+ /* Same box as the poster it replaces, so starting playback moves nothing. */
148
+ .stage video{display:block;max-width:100%;max-height:100%;width:auto;height:auto;
149
+ object-fit:contain;background:#000}
150
+
151
+ /* The audio player sits in the middle of the same dark stage the picture would
152
+ have filled, so the card is one shape for all three media types. The native
153
+ control is used as-is: it is the widget every user already knows, it is
154
+ keyboard accessible and screen-reader labelled for free, and it carries the
155
+ scrubber and volume that a hand-rolled button would have to reinvent. */
156
+ .aud-wrap{display:flex;flex-direction:column;align-items:center;gap:12px;
157
+ padding:0 18px;width:100%;max-width:420px}
158
+ .aud-wrap audio{width:100%}
159
+ .aud-label{font-size:12px;color:#a1a1aa;letter-spacing:.02em}
160
+
161
+ /* min-height reserves the row the buttons will occupy, so the card is the SAME
162
+ * height while it waits as it is when it finishes. Without it the footer is
163
+ * empty during the wait and the card grew 46px on completion — a small jump,
164
+ * but a jump, and the point of a fixed stage is that nothing below the card
165
+ * moves when the result lands. 28px is one button's height. */
166
+ .foot{display:flex;align-items:center;gap:6px;padding:9px 10px;flex-wrap:wrap;
167
+ min-height:28px;box-sizing:content-box}
168
+ .btn{font:inherit;font-size:12px;padding:5px 10px;border-radius:7px;cursor:pointer;
169
+ border:1px solid var(--color-border);background:var(--color-background-primary);
170
+ color:var(--color-text-primary)}
171
+ .btn:hover{background:var(--color-background-secondary)}
172
+ .btn.primary{background:var(--color-text-primary);color:var(--color-background-primary);
173
+ border-color:var(--color-text-primary)}
174
+ .spacer{flex:1;min-width:0}
175
+ /* The meta line yields before the buttons do. It was nowrap and unshrinkable,
176
+ * so on a narrower pane it pushed the action row onto a second line — the
177
+ * buttons are what people click, and "2 coins · 1344 × 768 · 17s" is what they
178
+ * can afford to lose the end of. */
179
+ .btn{flex:none}
180
+ .meta{font-size:11.5px;color:var(--color-text-secondary);
181
+ font-variant-numeric:tabular-nums;white-space:nowrap;
182
+ min-width:0;overflow:hidden;text-overflow:ellipsis}
183
+ .err{padding:15px 13px;font-size:12.5px;line-height:1.6;color:var(--color-text-secondary)}
184
+ .err b{color:var(--color-text-primary);font-weight:600;display:block;margin-bottom:3px}
185
+ [hidden]{display:none!important}
186
+ </style>
187
+ </head>
188
+ <body>
189
+ <div class="card">
190
+ <div class="head">
191
+ <img class="mark" id="mark" alt="" hidden>
192
+ <span class="mark" id="markFallback"></span>
193
+ <span class="title" id="title">Vidofy</span>
194
+ <span class="pill live" id="pill">starting</span>
195
+ </div>
196
+
197
+ <div class="stage" id="stage">
198
+ <div class="wait" id="wait">
199
+ <div class="spin" id="spin"></div>
200
+ <div class="pct" id="pct" hidden></div>
201
+ <div class="wait-note" id="note">checking…</div>
202
+ <div class="bar" id="barTrack"><i id="bar"></i></div>
203
+ </div>
204
+ <img id="shot" alt="" hidden>
205
+ <!-- preload="none" so a card that is never played costs no bandwidth: the
206
+ signed url is only fetched once someone asks for it. playsinline keeps
207
+ iOS from hijacking the frame into its fullscreen player. -->
208
+ <video id="vid" hidden controls playsinline preload="none"></video>
209
+ <button class="play" id="play" type="button" aria-label="Play video" hidden></button>
210
+ <!-- Audio has no frame to show, so the stage holds a player instead of a
211
+ picture. No autoplay — a chat transcript that starts making noise on
212
+ its own is hostile, and the browser would refuse it without a gesture
213
+ anyway.
214
+
215
+ preload="metadata", not "none", to learn the one fact nobody else can
216
+ supply: how long the result actually is. A text of a few thousand
217
+ characters can produce seven minutes of speech, and until this landed
218
+ the card never said so.
219
+
220
+ What that costs, measured rather than assumed: Chrome buffered 6.2
221
+ seconds of a 430.7-second WAV to answer — about 300 KB of a 20.6 MB
222
+ file, 1.4% of it, and less again for a compressed format. Playback
223
+ does NOT start (verified paused with readyState 4), and the rest of
224
+ the file is still only fetched when the user presses play. -->
225
+
226
+ <div class="aud-wrap" id="audWrap" hidden>
227
+ <div class="aud-label" id="audLabel">Audio result</div>
228
+ <audio id="aud" controls preload="metadata"></audio>
229
+ </div>
230
+ <div class="stage-note" id="stageNote" hidden></div>
231
+ </div>
232
+
233
+ <div class="err" id="err" hidden><b id="errTitle"></b><span id="errBody"></span></div>
234
+
235
+ <div class="foot" id="foot">
236
+ <button class="btn primary" id="bOpen" hidden>Open full size</button>
237
+ <button class="btn" id="bDownload" hidden>Download</button>
238
+ <button class="btn" id="bVidofy" hidden>Open on Vidofy</button>
239
+ <!-- Shown ONLY when the file on screen actually carries the watermark
240
+ (server-stated, `m_watermarked`). Never over a clean image:
241
+ offering to remove something that is not there reads as broken, and a
242
+ subscriber must never be shown an upgrade they already bought. -->
243
+ <button class="btn" id="bClean" hidden>Remove watermark</button>
244
+ <button class="btn" id="bReuse" hidden>Use as input</button>
245
+ <button class="btn" id="bReprompt" hidden>Reprompt</button>
246
+ <span class="spacer"></span>
247
+ <span class="meta" id="meta"></span>
248
+ </div>
249
+ </div>
250
+
251
+ <script>
252
+ (function () {
253
+ 'use strict';
254
+
255
+ /* ── JSON-RPC over postMessage ────────────────────────────────────────
256
+ The view is a sandboxed iframe; every capability it has is a message to
257
+ the host. Requests carry an id and resolve when the matching response
258
+ arrives; notifications carry none. */
259
+ var nextId = 1;
260
+ var pending = new Map();
261
+
262
+ function call(method, params) {
263
+ var id = nextId++;
264
+ return new Promise(function (resolve, reject) {
265
+ pending.set(id, { resolve: resolve, reject: reject });
266
+ parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');
267
+ setTimeout(function () {
268
+ if (pending.has(id)) { pending.delete(id); reject(new Error(method + ' timed out')); }
269
+ }, 30000);
270
+ });
271
+ }
272
+ function notify(method, params) {
273
+ parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');
274
+ }
275
+
276
+ var el = function (id) { return document.getElementById(id); };
277
+ /* fails: CONSECUTIVE failed polls, reset on every answer — this is what the
278
+ * give-up threshold counts. tries stays a plain total, for the log.
279
+ * polling: one request in flight at a time, so the notification and a
280
+ * pending timer cannot start two chains of setTimeout. */
281
+ var state = { id: null, done: false, timer: null, tries: 0, fails: 0,
282
+ polling: false, iconSet: false,
283
+ // isVideo/poster decide what a click on the stage does;
284
+ // playing guards against a second click restarting the file.
285
+ isVideo: false, poster: '', playing: false,
286
+ url: '', download: '', vidofy: '' };
287
+
288
+ /* ── rendering ────────────────────────────────────────────────────── */
289
+
290
+ /* Height reporting — the thing that decides whether there is a gap.
291
+ *
292
+ * The host sets the iframe to whatever this reports, in both directions,
293
+ * and until it hears anything it uses a default of its own — which is tall.
294
+ * So every state change has to report, promptly and accurately.
295
+ *
296
+ * Measure the CARD, not the document. documentElement.scrollHeight is the
297
+ * viewport when the content is shorter than it, so a small error card kept
298
+ * reporting the frame's own height and the gap below it never closed: the
299
+ * card asked for exactly the space it already had.
300
+ *
301
+ * Reported after a frame, because a height read in the same tick as the DOM
302
+ * change is the height from before it. */
303
+ var card = document.querySelector('.card');
304
+ var lastH = 0;
305
+ var sizeTimer = null;
306
+
307
+ function emitSize() {
308
+ /* getBoundingClientRect forces layout, so this reads the height AFTER
309
+ * whatever DOM change just happened — no waiting required. */
310
+ var r = card.getBoundingClientRect();
311
+ var h = Math.ceil(r.height);
312
+ if (h <= 0 || h === lastH) return;
313
+ lastH = h;
314
+ notify('ui/notifications/size-changed', { height: h, width: Math.ceil(r.width) });
315
+ }
316
+
317
+ /* Report now, and once more shortly after.
318
+ *
319
+ * The first version used requestAnimationFrame and a ResizeObserver, and
320
+ * MEASURED IN A HOST HARNESS neither one ever fired: both are driven by the
321
+ * rendering lifecycle, and a frame the browser is not painting — hidden,
322
+ * backgrounded, scrolled out of view — gets no lifecycle at all. The card
323
+ * then never reported a height, the host kept its default, and the grey gap
324
+ * this whole mechanism exists to close came back.
325
+ *
326
+ * Timers are not tied to painting. So: measure synchronously for the change
327
+ * that just happened, then again after a moment for the layout that settles
328
+ * late — a decoded image, a wrapped line, a font arriving. */
329
+ function reportSize() {
330
+ emitSize();
331
+ clearTimeout(sizeTimer);
332
+ sizeTimer = setTimeout(emitSize, 150);
333
+ }
334
+
335
+ /* Still observed when the environment allows it — it catches changes no
336
+ * code here initiated. It is a bonus, never the mechanism. */
337
+ if (typeof ResizeObserver !== 'undefined') {
338
+ new ResizeObserver(function () { emitSize(); }).observe(card);
339
+ }
340
+
341
+ /** The provider's logo, once. Silent on failure — see .mark. */
342
+ function setIcon(url) {
343
+ if (!url || state.iconSet) return;
344
+ var img = el('mark');
345
+ img.onload = function () {
346
+ state.iconSet = true;
347
+ el('markFallback').hidden = true;
348
+ img.hidden = false;
349
+ reportSize();
350
+ };
351
+ img.onerror = function () { img.hidden = true; };
352
+ img.src = url;
353
+ }
354
+
355
+ function setWaiting(pct, label, model) {
356
+ /* Only ever called while a job is unfinished, so any media still
357
+ mounted belongs to a previous result this card is being reused for.
358
+ Audio is checked by its wrapper rather than state.playing: the native
359
+ controls start it without going through playInline, so that flag
360
+ says nothing about a sound file that is mid-sentence. */
361
+ if (state.playing || el('audWrap').hidden === false) stopMedia();
362
+ if (model) el('title').textContent = model;
363
+ el('pill').textContent = 'generating';
364
+ el('pill').className = 'pill live';
365
+ /* A number when the server has one, a turning ring when it does not —
366
+ * never both, and never the dash that used to stand in for a number and
367
+ * read as "nothing is happening". */
368
+ var known = pct !== null && pct !== undefined;
369
+ el('pct').hidden = !known;
370
+ el('spin').hidden = known;
371
+ if (known) el('pct').textContent = Math.round(pct) + '%';
372
+ /* The TRACK carries the indeterminate class, the FILL carries the
373
+ width — so the two modes cannot both apply and leave a segment
374
+ sliding at some stale percentage. */
375
+ el('barTrack').classList.toggle('indet', !known);
376
+ el('bar').style.width = known ? pct + '%' : '';
377
+ el('note').textContent = label || 'working…';
378
+ reportSize();
379
+ }
380
+
381
+ function setDone(d) {
382
+ state.done = true;
383
+ /* Cleared per result, so a card reused for a second generation cannot
384
+ caption it with the first one's length while its own header loads. */
385
+ state.mediaSeconds = 0;
386
+ // Held for the buttons, which are one-liners on purpose.
387
+ state.url = d.url || '';
388
+ state.download = d.download_url || '';
389
+ state.vidofy = d.vidofy_url || '';
390
+ state.pricing = d.pricing_url || '';
391
+ el('wait').hidden = true;
392
+ el('pill').textContent = 'done';
393
+ el('pill').className = 'pill';
394
+ if (d.model) el('title').textContent = d.model;
395
+
396
+ var isVideo = d.media_type === 'video';
397
+ var isAudio = d.media_type === 'audio';
398
+ /* Only a STILL belongs in the <img>.
399
+ *
400
+ * This read `isVideo ? poster : d.url`, so an audio result put its own
401
+ * .wav into an image tag. The decode failed, img.onerror fired, and the
402
+ * card called fail() — a successful, already-charged generation
403
+ * rendered as "The preview link expired." Measured 2026-09-11 against a
404
+ * real row: pill "failed", shot src ending .wav, stage hidden. The
405
+ * "Audio — nothing to show" branch below could never run, because it
406
+ * needs an EMPTY pic and d.url is always set on success. */
407
+ /* The poster for BOTH kinds now. An image gained one on 2026-09-11 —
408
+ before that poster_url was the output key itself, so this box pulled
409
+ the whole file (1.5 MB measured) to fill 380 pixels. Falling back to
410
+ d.url keeps every older row working, where the two are still the
411
+ same string. state.url is untouched: "Open full size" must still
412
+ reach the real file. */
413
+ var pic = isAudio ? '' : isVideo ? (d.poster_url || '') : (d.poster_url || d.url || '');
414
+ // Held for playInline, which runs on a click long after this.
415
+ state.isVideo = isVideo;
416
+ state.poster = isVideo ? pic : '';
417
+
418
+ if (pic) {
419
+ var img = el('shot');
420
+ img.src = pic;
421
+ img.hidden = false;
422
+ img.className = isVideo ? 'tap' : '';
423
+ img.onload = reportSize;
424
+ /* A signed url expires; if it has by the time the host paints,
425
+ say so rather than showing a broken frame. */
426
+ img.onerror = function () {
427
+ img.hidden = true;
428
+ fail('The preview link expired.', 'The file is fine — use Open or Download.');
429
+ };
430
+ el('play').hidden = !isVideo;
431
+ } else if (isAudio && d.url) {
432
+ /* Audio is listened to, not looked at — so the stage carries a
433
+ player rather than a sentence telling the user to go elsewhere,
434
+ which is what it did until 2026-09-11. Allowed by the same CSP
435
+ line as the video: resourceDomains covers media-src, and audio
436
+ comes from the identical private R2 host (it is ALWAYS private —
437
+ the worker's public upload is guarded by `!= "audio"`). */
438
+ var a = el('aud');
439
+ /* The element is the only party that knows the real length, and it
440
+ knows it as soon as the header lands. Guarded because a stream
441
+ of unknown length reports Infinity, and NaN before it loads —
442
+ neither is a duration worth printing. */
443
+ a.onloadedmetadata = function () {
444
+ if (isFinite(a.duration) && a.duration > 0) {
445
+ state.mediaSeconds = a.duration;
446
+ renderMeta();
447
+ reportSize();
448
+ }
449
+ };
450
+ a.src = d.url;
451
+ a.onerror = function () {
452
+ el('audWrap').hidden = true;
453
+ el('stageNote').textContent = 'Could not load the audio — the link may have expired. Use Download or Open on Vidofy.';
454
+ el('stageNote').hidden = false;
455
+ reportSize();
456
+ };
457
+ el('audWrap').hidden = false;
458
+ } else {
459
+ // A video or image whose poster never got made. The stage keeps its
460
+ // size either way; only its contents differ.
461
+ el('stageNote').textContent = isVideo
462
+ // No poster, but the file itself is fine — the Play button
463
+ // below loads it into the same stage.
464
+ ? 'No preview frame for this result. Press Play to watch it here.'
465
+ : 'No preview frame for this result. Open it with the button below.';
466
+ el('stageNote').hidden = false;
467
+ }
468
+
469
+ el('bOpen').hidden = !d.url;
470
+ // "full size" means nothing for a sound file, and the player is already
471
+ // on the stage — this button is only the way out to the raw file.
472
+ el('bOpen').textContent = isVideo ? 'Play' : isAudio ? 'Open' : 'Open full size';
473
+
474
+ /* Which button is the obvious one depends on the medium.
475
+ *
476
+ * On a sound card the player is already on the stage, so Open — a raw
477
+ * .wav in a browser tab — is the least useful control there, while
478
+ * keeping the file is what the listener actually wants next. Owner
479
+ * decision 2026-09-11. Open stays, as the way out to the raw file,
480
+ * just not as the highlighted one.
481
+ *
482
+ * Both classes are assigned outright rather than toggled: a card
483
+ * re-rendered for a second result must never end up with two primary
484
+ * buttons, or none. */
485
+ el('bOpen').className = isAudio ? 'btn' : 'btn primary';
486
+ el('bDownload').className = isAudio ? 'btn primary' : 'btn';
487
+ el('bDownload').hidden = !d.download_url;
488
+ el('bVidofy').hidden = !d.vidofy_url;
489
+ // Both conditions, not either: watermarked says the button is honest,
490
+ // pricing_url says it has somewhere to go. The server sends the second
491
+ // only alongside the first, so this is belt and braces against an older
492
+ // or partial payload rather than two independent facts.
493
+ el('bClean').hidden = !(d.watermarked && d.pricing_url);
494
+ el('bReuse').hidden = !(d.id && !isVideo);
495
+ el('bReprompt').hidden = !d.id;
496
+
497
+ var bits = [];
498
+ if (d.credits !== null && d.credits !== undefined) bits.push(d.credits + ' coins');
499
+ if (d.dimensions) bits.push(d.dimensions);
500
+ // Already worded and formatted by cardState ("took 3m 54s") — it is
501
+ // generation time, not the clip's length, and the label says so.
502
+ if (d.duration_label) bits.push(d.duration_label);
503
+ state.bits = bits;
504
+ renderMeta();
505
+ reportSize();
506
+ }
507
+
508
+ /** Seconds as "47s" / "3m 54s" — the spelling cardState uses server-side.
509
+ * Duplicated here of necessity: this number is MEASURED in the browser,
510
+ * so the server has nothing to format. Keep the two spellings identical. */
511
+ function prettySeconds(secs) {
512
+ var s = Math.max(0, Math.round(secs));
513
+ return s < 60 ? s + 's' : Math.floor(s / 60) + 'm ' + (s % 60) + 's';
514
+ }
515
+
516
+ /* The footer, plus the media's OWN length once the player reports it.
517
+ *
518
+ * Measured, never estimated. A words-to-seconds guess was considered and
519
+ * rejected on measurement: across real TTS outputs the rate ranges from
520
+ * 0.13 to 0.81 seconds per word — 6.4x — and the word count itself is
521
+ * wrong for non-Latin scripts. The element knows the true answer, so it
522
+ * is the one that gets asked.
523
+ *
524
+ * "of audio" and not a bare duration, because "took 3m 54s" already sits
525
+ * on this line and two bare durations side by side cannot be told apart. */
526
+ function renderMeta() {
527
+ var bits = (state.bits || []).slice();
528
+ if (state.mediaSeconds) bits.push(prettySeconds(state.mediaSeconds) + ' of audio');
529
+ el('meta').textContent = bits.join(' · ');
530
+ }
531
+
532
+ /**
533
+ * Stop and unload the player.
534
+ *
535
+ * Hiding the stage does NOT stop a <video> — it keeps playing, audibly,
536
+ * behind a box the user can no longer see or pause. So every path that
537
+ * takes the result off screen comes through here first. Dropping the src
538
+ * also releases the buffered file rather than leaving it in memory for a
539
+ * card the conversation has scrolled past.
540
+ */
541
+ function stopMedia() {
542
+ [el('vid'), el('aud')].forEach(function (m) {
543
+ if (!m) return;
544
+ try { m.pause(); } catch (_e) { /* not started; nothing to pause */ }
545
+ m.removeAttribute('src');
546
+ if (typeof m.load === 'function') m.load(); // abandons a request in flight
547
+ });
548
+ el('vid').hidden = true;
549
+ el('audWrap').hidden = true;
550
+ state.playing = false;
551
+ }
552
+
553
+ function fail(title, body) {
554
+ state.done = true;
555
+ stopMedia();
556
+ el('wait').hidden = true;
557
+ /* The stage goes entirely, not just its contents. It holds a 16:9 box
558
+ * now, so leaving it would put a large empty dark box above a one-line
559
+ * error — the failed card should be the SHORTEST of the three, not the
560
+ * same size as a result that does not exist. */
561
+ el('stage').hidden = true;
562
+ el('pill').textContent = 'failed';
563
+ el('pill').className = 'pill bad';
564
+ el('errTitle').textContent = title;
565
+ el('errBody').textContent = body || '';
566
+ el('err').hidden = false;
567
+ reportSize();
568
+ }
569
+
570
+ /* ── polling, through the HOST ────────────────────────────────────────
571
+ Not a fetch to the API. That would need this page to hold the user's
572
+ MCP token, and this page is HTML the host renders — a credential put
573
+ here is a credential handed to the frame. tools/call goes through the
574
+ host to the server, which is where the token already lives. The tool is
575
+ registered app-only, so the model never sees these calls and waiting
576
+ costs no tokens. */
577
+ function poll() {
578
+ if (state.done || !state.id) return;
579
+ /* One in flight at a time. Both the host notification and a pending
580
+ * timer can call this, and a slow round trip used to let a second start
581
+ * on top of the first — two chains of setTimeout from then on, each
582
+ * doubling at the next overlap. */
583
+ if (state.polling) return;
584
+ state.polling = true;
585
+ clearTimeout(state.timer);
586
+ state.tries++;
587
+ call('tools/call', { name: 'generation_card_state', arguments: { id: state.id } })
588
+ .then(function (res) {
589
+ /* An isError result is an ANSWER, not a blip: the server was
590
+ * reached and said no. Retrying it 200 times changes nothing
591
+ * except how long the user waits to be told. */
592
+ if (res && res.isError && !(res.structuredContent && res.structuredContent.status === 'success')) {
593
+ var m = res.content && res.content[0] && res.content[0].text;
594
+ return fail(m || 'The generation could not be checked.', 'Ask for its status.');
595
+ }
596
+ var d = res && res.structuredContent;
597
+ if (!d) throw new Error('no state');
598
+ state.fails = 0; // reached it — start the blip count over
599
+ if (d.status === 'success') return setDone(d);
600
+ if (d.done || d.status === 'error' || d.status === 'failed') {
601
+ return fail(
602
+ d.error || 'The generation did not finish.',
603
+ d.refunded ? 'Your coins were refunded.' : ''
604
+ );
605
+ }
606
+ setWaiting(d.progress, d.wait_label, d.model);
607
+ state.timer = setTimeout(poll, d.poll_after_ms || 3000);
608
+ })
609
+ .catch(function () {
610
+ /* A blip is not a failure — back off and keep trying, up to a
611
+ * point. Ten minutes of SILENCE is a failure worth reporting.
612
+ *
613
+ * Counted as CONSECUTIVE failures, which is what "silence"
614
+ * means. It used to test state.tries, incremented on every poll
615
+ * including all the successful ones, so the threshold was really
616
+ * "this generation has been watched for a while" — a job past
617
+ * 200 polls died on its first hiccup, and the comment describing
618
+ * ten minutes of silence described something the code did not
619
+ * do. At 5s a retry, 120 consecutive misses IS ten minutes. */
620
+ state.fails++;
621
+ if (state.fails > 120) {
622
+ return fail('Lost contact with the generation.', 'Ask for its status.');
623
+ }
624
+ state.timer = setTimeout(poll, 5000);
625
+ })
626
+ .then(function () { state.polling = false; }, function () { state.polling = false; });
627
+ }
628
+
629
+ /* ── host → view ──────────────────────────────────────────────────── */
630
+ addEventListener('message', function (e) {
631
+ var m = e.data;
632
+ if (!m || m.jsonrpc !== '2.0') return;
633
+
634
+ if (m.id !== undefined && pending.has(m.id)) {
635
+ var p = pending.get(m.id);
636
+ pending.delete(m.id);
637
+ return m.error ? p.reject(new Error(m.error.message || 'error')) : p.resolve(m.result);
638
+ }
639
+
640
+ if (m.method === 'ui/notifications/tool-result') {
641
+ var d = (m.params && m.params.structuredContent) || {};
642
+ state.id = d.id || state.id;
643
+ /* model_name, not model: the latter is the database key
644
+ * (Flux_schnell_t2i) the agent needs for chaining, and a person
645
+ * should never be shown it. The card used to print it for the whole
646
+ * wait and then swap to the real name on completion, so one
647
+ * generation appeared to change model halfway. Falls back, so an
648
+ * older server still labels the card with something. */
649
+ var label = d.model_name || d.model;
650
+ if (label) el('title').textContent = label;
651
+ setIcon(d.model_icon);
652
+ if (d.status === 'success') return setDone(d);
653
+
654
+ /* A submit that FAILED renders a card too — the view is attached to
655
+ * the tool at list time, not to the result — so the two cases below
656
+ * have to end the card here. Without them it fell through to
657
+ * setWaiting()+poll(), and poll() returns on its first line when
658
+ * there is no id, so no timer was ever set and the card stayed at
659
+ * "checking…" for good over a generation that never existed.
660
+ *
661
+ * The second case is the belt to the first's braces: the server
662
+ * sends status:'error' on every failure now, but whether a host
663
+ * forwards structuredContent on an isError result is up to the host.
664
+ * No id and no success means there is nothing to poll either way. */
665
+ if (d.done || d.status === 'error' || d.status === 'failed') {
666
+ return fail(
667
+ d.error || 'The generation did not finish.',
668
+ d.refunded ? 'Your coins were refunded.' : ''
669
+ );
670
+ }
671
+ if (!(d.id || state.id)) {
672
+ return fail('The generation did not start.', d.error || '');
673
+ }
674
+
675
+ /* "checking…", not "about 16s".
676
+ *
677
+ * The host replays this notification every time the conversation is
678
+ * reopened, and it carries the ORIGINAL submit — which said
679
+ * processing, because that is what it said when it was new. So a
680
+ * generation finished days ago reopened as a card counting down 16
681
+ * seconds, until the first poll came back and replaced it with the
682
+ * image. For those few seconds the user is shown something that is
683
+ * not true, and the honest thing to say is that we are finding out.
684
+ *
685
+ * The estimate is still worth showing — but only once polling has
686
+ * confirmed the job really is running, which setWaiting does with
687
+ * the label the state tool returns. */
688
+ setWaiting(null, 'checking…', label);
689
+ poll();
690
+ }
691
+ });
692
+
693
+ /* ── playing the video where the user is looking ───────────────────── */
694
+
695
+ /**
696
+ * Swap the poster for the real file and start it, inside the card.
697
+ *
698
+ * Allowed by the card's own CSP: _meta.ui.csp.resourceDomains maps to
699
+ * media-src as well as img-src (MCP Apps spec, apps.mdx), and the output
700
+ * and its poster come from the SAME host — measured — and that host is
701
+ * matched by the declared https://*.r2.cloudflarestorage.com. The poster
702
+ * already renders from it,
703
+ * which is the proof the origin passes.
704
+ *
705
+ * play() is called inside the click handler, never outside one: a gesture
706
+ * is what the autoplay policy requires, and a promise rejection here means
707
+ * the browser refused, not that the file is bad — so the controls stay and
708
+ * the user can press the native button.
709
+ */
710
+ function playInline() {
711
+ if (!state.url) return;
712
+ var v = el('vid');
713
+
714
+ /* Already loaded. Resume or replay in place — do NOT re-assign src,
715
+ which would throw away the buffered file and restart the download.
716
+ Without this branch the footer Play button was dead once a clip
717
+ ended: state.playing stayed true and every press did nothing. */
718
+ if (state.playing) {
719
+ if (v.paused) { var again = v.play(); if (again && again.catch) again.catch(function () {}); }
720
+ return;
721
+ }
722
+ state.playing = true;
723
+
724
+ /* Keep the poster frame under the video while it buffers, so the stage
725
+ never flashes black between the click and the first frame. */
726
+ if (state.poster) v.poster = state.poster;
727
+ v.src = state.url;
728
+
729
+ /* A signed url expires in eight hours; a card read the next morning
730
+ gets a media error, not a file. Say so, and offer the route that
731
+ still works rather than leaving a dead frame. */
732
+ v.onerror = function () {
733
+ state.playing = false;
734
+ v.hidden = true;
735
+ v.removeAttribute('src');
736
+ el('shot').hidden = !state.poster;
737
+ el('play').hidden = false;
738
+ el('stageNote').textContent = 'Could not play here — the link may have expired. Use Download or Open on Vidofy.';
739
+ el('stageNote').hidden = false;
740
+ reportSize();
741
+ };
742
+
743
+ el('shot').hidden = true;
744
+ el('play').hidden = true;
745
+ el('stageNote').hidden = true;
746
+ v.hidden = false;
747
+ reportSize();
748
+
749
+ var p = v.play();
750
+ /* Older engines return undefined instead of a promise. */
751
+ if (p && typeof p.catch === 'function') {
752
+ p.catch(function () {
753
+ /* Refused (policy, or a codec it will not start on its own).
754
+ The element and its controls stay — pressing play on them is
755
+ a second gesture, which is exactly what a refusal wants. */
756
+ });
757
+ }
758
+ }
759
+
760
+ /* ── view → host: the three documented actions ─────────────────────── */
761
+ /* For a video the primary button plays here; the external routes are
762
+ Download and Open on Vidofy, which are their own buttons. For an image
763
+ there is nothing to play, so it opens full size as before. */
764
+ el('bOpen').onclick = function () {
765
+ if (!state.url) return;
766
+ if (state.isVideo) { playInline(); return; }
767
+ call('ui/open-link', { url: state.url });
768
+ };
769
+ el('play').onclick = playInline;
770
+ el('shot').onclick = function () { if (state.isVideo) playInline(); };
771
+
772
+ /* Download asks for a FRESH link at the moment of the click.
773
+ *
774
+ * Both urls are presigned and both expire — the viewing one in eight hours,
775
+ * the download one in ten minutes, because a link that writes a file to
776
+ * disk is a worse thing to leave lying around. The card was handing over
777
+ * whichever url it happened to be holding, so a user who read the reply and
778
+ * clicked a while later got R2's XML expiry error instead of their file.
779
+ *
780
+ * One extra round trip, only when the button is pressed. If it fails, fall
781
+ * back to the held url rather than doing nothing — an expired link that
782
+ * shows an error is still better than a button that ignores you. */
783
+ el('bDownload').onclick = function () {
784
+ if (!state.id) return;
785
+ var btn = el('bDownload');
786
+ if (btn.disabled) return; // ignore a second click
787
+ /* Say what is happening. The fresh-link round trip goes host → server →
788
+ * Vidofy → R2 and back, so it is sometimes instant and sometimes a
789
+ * second or two — and a button that looks unchanged for two seconds
790
+ * reads as a button that did not work, which is when people click it
791
+ * again. */
792
+ btn.disabled = true;
793
+ var was = btn.textContent;
794
+ btn.textContent = 'Preparing…';
795
+ call('tools/call', { name: 'generation_card_state', arguments: { id: state.id } })
796
+ .then(function (res) {
797
+ var fresh = res && res.structuredContent && res.structuredContent.download_url;
798
+ if (fresh) state.download = fresh;
799
+ })
800
+ .catch(function () { /* keep whatever we already had */ })
801
+ .finally(function () {
802
+ btn.disabled = false;
803
+ btn.textContent = was;
804
+ if (state.download) call('ui/open-link', { url: state.download });
805
+ });
806
+ };
807
+
808
+ el('bVidofy').onclick = function () {
809
+ if (state.vidofy) call('ui/open-link', { url: state.vidofy });
810
+ };
811
+ // Opens the pricing page. Deliberately NOT a download: the clean file is
812
+ // gated on the site (the download route refuses a free account and
813
+ // redirects here), and a button that quietly produced it would be the
814
+ // gate's hole.
815
+ el('bClean').onclick = function () {
816
+ if (state.pricing) call('ui/open-link', { url: state.pricing });
817
+ };
818
+ el('bReuse').onclick = function () {
819
+ call('ui/message', { role: 'user', content: [{ type: 'text',
820
+ text: 'Use generation ' + state.id + ' as the input image for the next generation.' }] });
821
+ };
822
+ el('bReprompt').onclick = function () {
823
+ call('ui/message', { role: 'user', content: [{ type: 'text',
824
+ text: 'Generate this again with a different prompt — the previous one was ' + state.id + '.' }] });
825
+ };
826
+
827
+ /* ── handshake, in the order the spec requires ─────────────────────── */
828
+ call('ui/initialize', {
829
+ protocolVersion: '2026-01-26',
830
+ capabilities: {},
831
+ clientInfo: { name: 'Vidofy generation card', version: '1.0.0' }
832
+ }).then(function (res) {
833
+ // Adopt the host's palette so the card belongs to the conversation.
834
+ var vars = res && res.hostContext && res.hostContext.styles && res.hostContext.styles.variables;
835
+ if (vars) {
836
+ Object.keys(vars).forEach(function (k) {
837
+ document.documentElement.style.setProperty(
838
+ k.charAt(0) === '-' ? k : '--' + k, String(vars[k])
839
+ );
840
+ });
841
+ }
842
+
843
+ /* Respect a ceiling if the host names one. A tall portrait image would
844
+ * otherwise ask for a frame taller than the pane allows, and the host
845
+ * decides how to reconcile that — better to keep the picture inside the
846
+ * card and let it scroll than to find out. */
847
+ /* Tighten the stage's ceiling if the host's pane is shorter than the
848
+ * card would like. maxHeight, not height: the stage is sized by its
849
+ * 16:9 ratio now, and setting a height would override that and stretch
850
+ * or squash it. 96px is the header and footer around it; 160 is the
851
+ * floor below which a picture stops being worth showing. */
852
+ var dims = res && res.hostContext && res.hostContext.containerDimensions;
853
+ if (dims && typeof dims.maxHeight === 'number' && dims.maxHeight > 0) {
854
+ // The stage's only ceiling, and it comes from the host rather than
855
+ // from a number picked here. 96px is the header and footer around
856
+ // it; 160 is the floor below which a picture stops being worth
857
+ // showing at all.
858
+ el('stage').style.maxHeight = Math.max(160, dims.maxHeight - 96) + 'px';
859
+ }
860
+ }).catch(function () { /* a host that answers nothing still gets the fallbacks */ })
861
+ .finally(function () {
862
+ // The host MUST NOT send anything before this, so it goes last.
863
+ notify('ui/notifications/initialized', {});
864
+ reportSize();
865
+ });
866
+ })();
867
+ </script>
868
+ </body>
869
+ </html>