@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.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/backend.d.ts +133 -0
- package/dist/backend.js +455 -0
- package/dist/config.d.ts +85 -0
- package/dist/config.js +207 -0
- package/dist/heartbeat.d.ts +11 -0
- package/dist/heartbeat.js +70 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +885 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +661 -0
- package/dist/log.d.ts +19 -0
- package/dist/log.js +22 -0
- package/dist/map/b2c.d.ts +139 -0
- package/dist/map/b2c.js +236 -0
- package/dist/media-duration.d.ts +33 -0
- package/dist/media-duration.js +137 -0
- package/dist/oauth/authorize.d.ts +61 -0
- package/dist/oauth/authorize.js +275 -0
- package/dist/oauth/clients.d.ts +137 -0
- package/dist/oauth/clients.js +621 -0
- package/dist/oauth/ratelimit.d.ts +92 -0
- package/dist/oauth/ratelimit.js +116 -0
- package/dist/oauth/store.d.ts +135 -0
- package/dist/oauth/store.js +277 -0
- package/dist/oauth/token.d.ts +29 -0
- package/dist/oauth/token.js +165 -0
- package/dist/schema.d.ts +147 -0
- package/dist/schema.js +629 -0
- package/dist/tools/account.d.ts +28 -0
- package/dist/tools/account.js +130 -0
- package/dist/tools/generation.d.ts +102 -0
- package/dist/tools/generation.js +1194 -0
- package/dist/tools/info.d.ts +50 -0
- package/dist/tools/info.js +95 -0
- package/dist/ui/generation-card.html +869 -0
- package/package.json +79 -0
- package/server.json +36 -0
|
@@ -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>
|