@ericrisco/rsc 3.1.3 → 3.1.4

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,352 @@
1
+ /*!
2
+ * scroll-film scrub engine — part of rsc-harness (MIT).
3
+ * Scroll position becomes the playhead of a chain of pre-rendered clips.
4
+ * Vanilla JS, no dependencies, no framework. API and wiring: ../references/engine.md
5
+ */
6
+ (function (root, factory) {
7
+ var api = factory();
8
+ if (typeof module === 'object' && module && module.exports) module.exports = api;
9
+ if (root) root.ScrollFilm = api;
10
+ })(typeof window !== 'undefined' ? window : this, function () {
11
+ 'use strict';
12
+
13
+ var TOUCH_QUERY = '(hover: none) and (pointer: coarse)';
14
+
15
+ function clamp(x, lo, hi) { return Math.min(hi, Math.max(lo, x)); }
16
+ function smoothstep(x) { x = clamp(x, 0, 1); return x * x * (3 - 2 * x); }
17
+
18
+ // Remaps a shot's progress so the camera slows in the middle (where the copy sits) and
19
+ // hurries near the seams. f(0)=0 and f(1)=1 for every strength, so seam frames never move.
20
+ // Monotone up to strength 1, which is a momentary stop at the midpoint.
21
+ function hold(x, strength) {
22
+ var s = clamp(strength || 0, 0, 1);
23
+ if (!s) return x;
24
+ return x + (s * Math.sin(2 * Math.PI * x)) / (2 * Math.PI);
25
+ }
26
+
27
+ // Lays the shots end to end on the scroll axis. lengths are in viewport heights.
28
+ function planBands(lengths, viewport) {
29
+ var at = 0;
30
+ return lengths.map(function (len) {
31
+ var band = { start: at, end: at + len * viewport };
32
+ at = band.end;
33
+ return band;
34
+ });
35
+ }
36
+
37
+ // A shot dissolves in over `fade` px centred on its start seam, on top of its predecessor,
38
+ // and stays opaque underneath its successor until that one is fully in. So the stack never
39
+ // shows the page through it, and the dissolve blends the two frames the seam handed off.
40
+ function shotOpacity(i, y, bands, fade) {
41
+ var band = bands[i];
42
+ var half = fade / 2;
43
+ var enter = 1;
44
+ if (i > 0) enter = fade > 0 ? smoothstep((y - (band.start - half)) / fade) : (y >= band.start ? 1 : 0);
45
+ var leave = i === bands.length - 1 || y < band.end + half ? 1 : 0;
46
+ return Math.min(enter, leave);
47
+ }
48
+
49
+ function mount(container, options) {
50
+ var opts = options || {};
51
+ var shots = (opts.shots || []).filter(Boolean);
52
+ if (!container || !shots.length) throw new Error('scroll-film: mount needs a container and at least one shot');
53
+
54
+ var doc = container.ownerDocument;
55
+ var win = doc.defaultView;
56
+ var touch = win.matchMedia(TOUCH_QUERY).matches;
57
+ var phone = touch || win.innerWidth <= (opts.phoneWidth || 860);
58
+ var reduce = win.matchMedia('(prefers-reduced-motion: reduce)').matches;
59
+ var saveData = !!(win.navigator.connection && win.navigator.connection.saveData);
60
+ var stillsOnly = !!opts.stillsOnly || reduce || saveData;
61
+ var fadeVh = opts.fade == null ? 0.1 : opts.fade;
62
+ var preloadVh = opts.preload == null ? 1.5 : opts.preload;
63
+ var ease = opts.ease == null ? 0.2 : clamp(opts.ease, 0.01, 1);
64
+ var minStep = phone ? 1 / 24 : 1 / 120; // seconds; a phone never seeks for less than a frame
65
+
66
+ injectStyles(doc);
67
+ container.classList.add('sf-root');
68
+ var stage = element(doc, 'div', 'sf-stage');
69
+ var layer = element(doc, 'div', 'sf-shots');
70
+ layer.setAttribute('aria-hidden', 'true');
71
+ var overlay = element(doc, 'div', 'sf-overlay');
72
+ stage.appendChild(layer);
73
+ stage.appendChild(overlay);
74
+ var track = element(doc, 'div', 'sf-track');
75
+ container.insertBefore(stage, container.firstChild);
76
+ container.appendChild(track);
77
+
78
+ var items = shots.map(function (shot, i) {
79
+ var node = element(doc, 'div', 'sf-shot');
80
+ node.style.zIndex = String(i + 1);
81
+ var img = element(doc, 'img', 'sf-poster');
82
+ img.alt = '';
83
+ img.decoding = 'async';
84
+ node.appendChild(img);
85
+ layer.appendChild(node);
86
+ return {
87
+ shot: shot,
88
+ section: shot.section == null ? i : shot.section,
89
+ node: node,
90
+ img: img,
91
+ poster: (phone && shot.posterMobile) || shot.poster || '',
92
+ src: (phone && shot.srcMobile) || shot.src || '',
93
+ video: null,
94
+ url: '',
95
+ state: 'idle',
96
+ cur: -1,
97
+ target: 0,
98
+ opacity: -1,
99
+ };
100
+ });
101
+ if (items[0].poster) {
102
+ items[0].img.setAttribute('fetchpriority', 'high');
103
+ showPoster(items[0]);
104
+ }
105
+
106
+ var aborter = typeof AbortController === 'function' ? new AbortController() : null;
107
+ var destroyed = false;
108
+ var raf = 0;
109
+ var vh = 0;
110
+ var top = 0;
111
+ var laidOutWidth = 0;
112
+ var bands = [];
113
+ var sections = [];
114
+ var lastState = '';
115
+
116
+ function showPoster(item) {
117
+ if (item.poster && !item.img.getAttribute('src')) item.img.src = item.poster;
118
+ }
119
+
120
+ function load(item) {
121
+ if (stillsOnly || item.state !== 'idle' || !item.src) return;
122
+ item.state = 'loading';
123
+ // A Blob is always fully seekable. Streaming from a host that ignores byte-range
124
+ // requests pins video.seekable to [0, 0] and every seek lands on frame 0.
125
+ win.fetch(item.src, aborter ? { signal: aborter.signal } : undefined)
126
+ .then(function (res) {
127
+ if (!res.ok) throw new Error('HTTP ' + res.status);
128
+ return res.blob();
129
+ })
130
+ .then(function (blob) {
131
+ if (destroyed) return;
132
+ var video = doc.createElement('video');
133
+ video.className = 'sf-video';
134
+ video.muted = true;
135
+ video.defaultMuted = true;
136
+ video.playsInline = true;
137
+ video.preload = 'auto';
138
+ // The attributes, not just the properties: iOS reads them to allow inline decode.
139
+ video.setAttribute('muted', '');
140
+ video.setAttribute('playsinline', '');
141
+ video.setAttribute('disablepictureinpicture', '');
142
+ video.setAttribute('disableremoteplayback', '');
143
+ item.url = win.URL.createObjectURL(blob);
144
+ video.addEventListener('loadedmetadata', function () { item.state = 'ready'; wake(); });
145
+ video.addEventListener('seeked', wake);
146
+ video.src = item.url;
147
+ item.node.appendChild(video);
148
+ item.video = video;
149
+ watchFirstPaint(item);
150
+ prime(video);
151
+ })
152
+ .catch(function () {
153
+ if (!destroyed) item.state = 'failed'; // the poster stays up: degraded, never blank
154
+ });
155
+ }
156
+
157
+ // Keep the poster until the video has really painted. iOS reports a seek as done on a
158
+ // muted video that never played and still shows nothing, so 'seeked' alone is not proof.
159
+ function watchFirstPaint(item) {
160
+ var live = function () { item.node.classList.add('is-live'); };
161
+ if (typeof item.video.requestVideoFrameCallback === 'function') item.video.requestVideoFrameCallback(live);
162
+ else item.video.addEventListener('seeked', live, { once: true });
163
+ }
164
+
165
+ // A muted play()+pause() unlocks decoding on iOS. Rejected before a user gesture in some
166
+ // power modes; the first-gesture handler below retries every loaded clip.
167
+ function prime(video) {
168
+ var p;
169
+ try { p = video.play(); } catch (err) { return; }
170
+ if (p && typeof p.then === 'function') p.then(function () { video.pause(); }, function () {});
171
+ else video.pause();
172
+ }
173
+
174
+ function dropGestureListeners() {
175
+ win.removeEventListener('pointerdown', onFirstGesture);
176
+ win.removeEventListener('touchstart', onFirstGesture);
177
+ }
178
+
179
+ function onFirstGesture() {
180
+ dropGestureListeners();
181
+ items.forEach(function (item) { if (item.video) prime(item.video); });
182
+ }
183
+
184
+ function scrollOffset() { return (win.scrollY || win.pageYOffset || 0) - top; }
185
+
186
+ function layout() {
187
+ vh = stage.clientHeight || win.innerHeight;
188
+ laidOutWidth = win.innerWidth;
189
+ bands = planBands(items.map(function (item) { return item.shot.length || 1; }), vh);
190
+ track.style.height = bands[bands.length - 1].end + 'px';
191
+ top = container.getBoundingClientRect().top + (win.scrollY || win.pageYOffset || 0);
192
+ sections = [];
193
+ items.forEach(function (item, i) {
194
+ var s = sections[item.section];
195
+ if (!s) sections[item.section] = { start: bands[i].start, end: bands[i].end };
196
+ else { s.start = Math.min(s.start, bands[i].start); s.end = Math.max(s.end, bands[i].end); }
197
+ });
198
+ wake();
199
+ }
200
+
201
+ // Phones fire resize whenever the URL bar slides. Re-laying out on a height-only change
202
+ // rewrites the track height mid-scroll and the page jumps, so touch only reacts to width.
203
+ function onResize() {
204
+ if (touch && win.innerWidth === laidOutWidth) return;
205
+ layout();
206
+ }
207
+
208
+ function wake() {
209
+ if (!raf && !destroyed) raf = win.requestAnimationFrame(frame);
210
+ }
211
+
212
+ function frame() {
213
+ raf = 0;
214
+ if (destroyed) return;
215
+ var y = scrollOffset();
216
+ var fade = fadeVh * vh;
217
+ var reach = preloadVh * vh;
218
+ var moving = false;
219
+
220
+ for (var i = 0; i < items.length; i++) {
221
+ var item = items[i];
222
+ var band = bands[i];
223
+ var near = y > band.start - reach && y < band.end + reach;
224
+ if (near) { showPoster(item); load(item); }
225
+
226
+ var opacity = shotOpacity(i, y, bands, fade);
227
+ if (opacity !== item.opacity) {
228
+ item.opacity = opacity;
229
+ item.node.style.opacity = String(opacity);
230
+ item.node.style.visibility = opacity > 0 ? 'visible' : 'hidden';
231
+ }
232
+
233
+ item.target = hold(clamp((y - band.start) / (band.end - band.start), 0, 1), item.shot.hold);
234
+ if (!near) { item.cur = -1; continue; } // out of reach: snap, don't glide, on return
235
+ if (item.state !== 'ready') continue;
236
+
237
+ var video = item.video;
238
+ var duration = video.duration;
239
+ if (!isFinite(duration) || duration <= 0) continue;
240
+ if (item.cur < 0) item.cur = item.target;
241
+ else item.cur += (item.target - item.cur) * ease;
242
+ if (Math.abs(item.target - item.cur) * duration < 0.002) item.cur = item.target;
243
+ if (item.cur !== item.target) moving = true;
244
+
245
+ // One seek in flight per clip. Queuing more while the decoder works is what freezes
246
+ // a phone on a fast flick; 'seeked' wakes the loop and it takes the latest target.
247
+ if (video.seeking) { moving = true; continue; }
248
+ var t = Math.min(item.cur * duration, duration - 0.01);
249
+ var gap = Math.abs(video.currentTime - t);
250
+ if (gap >= minStep || (gap > 0.001 && item.cur === item.target)) {
251
+ try { video.currentTime = t; } catch (err) { /* not seekable yet; next frame retries */ }
252
+ }
253
+ }
254
+
255
+ report(y);
256
+ if (moving) wake();
257
+ }
258
+
259
+ function report(y) {
260
+ if (typeof opts.onChange !== 'function') return;
261
+ var index = 0;
262
+ for (var i = 0; i < bands.length; i++) if (y >= bands[i].start) index = i;
263
+ var section = items[index].section;
264
+ var span = sections[section];
265
+ var total = bands[bands.length - 1].end;
266
+ var state = {
267
+ shot: index,
268
+ section: section,
269
+ sectionProgress: clamp((y - span.start) / (span.end - span.start), 0, 1),
270
+ progress: clamp(y / total, 0, 1),
271
+ };
272
+ var key = [state.shot, state.section, state.sectionProgress.toFixed(4), state.progress.toFixed(4)].join('|');
273
+ if (key === lastState) return;
274
+ lastState = key;
275
+ opts.onChange(state);
276
+ }
277
+
278
+ function jumpTo(section) {
279
+ var span = sections[section];
280
+ if (!span) return;
281
+ win.scrollTo({ top: top + (span.start + span.end) / 2, behavior: reduce ? 'auto' : 'smooth' });
282
+ }
283
+
284
+ function destroy() {
285
+ if (destroyed) return;
286
+ destroyed = true;
287
+ if (raf) win.cancelAnimationFrame(raf);
288
+ if (aborter) aborter.abort();
289
+ win.removeEventListener('scroll', wake);
290
+ win.removeEventListener('resize', onResize);
291
+ win.removeEventListener('orientationchange', layout);
292
+ win.removeEventListener('load', layout);
293
+ dropGestureListeners();
294
+ items.forEach(function (item) {
295
+ if (item.video) { item.video.removeAttribute('src'); item.video.load(); }
296
+ if (item.url) win.URL.revokeObjectURL(item.url);
297
+ });
298
+ stage.remove();
299
+ track.remove();
300
+ container.classList.remove('sf-root');
301
+ }
302
+
303
+ // Passive, and it only requests a frame: the per-frame work happens in rAF. Hosts that
304
+ // already own a scroll source (GSAP ScrollTrigger, motion's useScroll) pass listen:false
305
+ // and call update() from it instead.
306
+ if (opts.listen !== false) win.addEventListener('scroll', wake, { passive: true });
307
+ win.addEventListener('resize', onResize);
308
+ win.addEventListener('orientationchange', layout);
309
+ win.addEventListener('load', layout);
310
+ win.addEventListener('pointerdown', onFirstGesture, { passive: true });
311
+ win.addEventListener('touchstart', onFirstGesture, { passive: true });
312
+ layout();
313
+
314
+ return {
315
+ stage: stage,
316
+ overlay: overlay,
317
+ stillsOnly: stillsOnly,
318
+ update: wake,
319
+ relayout: layout,
320
+ jumpTo: jumpTo,
321
+ destroy: destroy,
322
+ };
323
+ }
324
+
325
+ function element(doc, tag, className) {
326
+ var node = doc.createElement(tag);
327
+ node.className = className;
328
+ return node;
329
+ }
330
+
331
+ // Layered, so any unlayered page rule (theme tokens, focus position) wins without a fight.
332
+ function injectStyles(doc) {
333
+ if (doc.getElementById('scroll-film-css')) return;
334
+ var style = doc.createElement('style');
335
+ style.id = 'scroll-film-css';
336
+ style.textContent = [
337
+ '@layer scroll-film {',
338
+ '.sf-root{position:relative;background:var(--film-bg,#0d0d0f)}',
339
+ '.sf-stage{position:sticky;top:0;height:100vh;height:100lvh;overflow:hidden}',
340
+ '.sf-shots,.sf-shot,.sf-overlay{position:absolute;inset:0}',
341
+ '.sf-shot{opacity:0;visibility:hidden}',
342
+ '.sf-poster,.sf-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:var(--film-focus,50% 45%)}',
343
+ '.sf-video{opacity:0}',
344
+ '.sf-shot.is-live .sf-video{opacity:1}',
345
+ '.sf-overlay{z-index:1000;pointer-events:none}',
346
+ '}',
347
+ ].join('\n');
348
+ doc.head.appendChild(style);
349
+ }
350
+
351
+ return { mount: mount, hold: hold, planBands: planBands, shotOpacity: shotOpacity };
352
+ });
@@ -0,0 +1,26 @@
1
+ # Evals — scroll-film
2
+
3
+ `cases.yaml` holds the trigger boundary and two capability rubrics. None of it is deterministic CI:
4
+ routing and capability are judged by a model or a person.
5
+
6
+ **Triggering.** Load only this skill and its nearest siblings (`design`, `ai-media`,
7
+ `landing-copy`, `performance`) so routing is honest. Run each `should_trigger` prompt in 3 fresh
8
+ sessions; it passes when scroll-film fires in the majority. Each `should_not_trigger` passes when
9
+ scroll-film does not fire; check that the named `route_to` sibling really owns it. Bar: at least
10
+ 90% of all trigger cases correct. `npm run routing:audit` adds a lexical check across the whole
11
+ catalog, which is a floor, not proof.
12
+
13
+ **Capability.** A/B on each scenario: the same prompt without the skill (baseline) and with it,
14
+ 3 trials each, one grader for both arms. Grade every `must_include` line pass/fail. The skill
15
+ ships when the treatment clears 8.5/10 and beats the baseline by at least +1.0
16
+ (`scripts/skill-behavior-rubric.md`). Items both arms miss are suspect; rewrite them as behaviour
17
+ or remove them (`../../author-skill/references/eval-authoring.md`).
18
+
19
+ **The artifact checks are deterministic.** `scripts/verify.sh` (encode and seam checks) and the
20
+ engine's pure functions (`hold`, `planBands`, `shotOpacity`) are covered by
21
+ `tests/scroll-film.test.js` in `npm test`. The browser behaviour of the engine (Blob seeking on a
22
+ host without range support, seek coalescing under CPU throttle, phone sources, height-only resize,
23
+ reduced motion, no-JS copy) was checked by hand in headless Chrome with generated clips. It is not
24
+ in CI, which has no browser. Re-run it after any change to `assets/scrub-engine.js`.
25
+
26
+ Re-run both axes after any edit to the description or the body: both are wording-sensitive.
@@ -0,0 +1,81 @@
1
+ skill: scroll-film
2
+
3
+ # scroll-film owns one job end to end: a page section whose picture is a chain of pre-rendered
4
+ # clips scrubbed by scroll — the chain (seams handed off as real frames), the encode (short GOP,
5
+ # Blob-seekable), the page (engine that holds up on phones) and the seam QA. It is NOT scroll
6
+ # animation without video (design), NOT a finished MP4 (ai-media), NOT the words (landing-copy),
7
+ # NOT a still image (replicate-images), NOT the provider call mechanics (fal), NOT page speed in
8
+ # general (performance), NOT a video rendered from code (remotion-video).
9
+
10
+ should_trigger:
11
+ - prompt: "Quiero una landing en la que, al hacer scroll, la cámara vuele por las fases de nuestro negocio sin cortes, como las webs de Apple, con vídeos generados con IA."
12
+ why: "The core job in Spanish: a scroll-scrubbed camera flight built from AI clips, from the brief to the page."
13
+
14
+ - prompt: "Build a scroll-driven hero where the camera flies from the tea farm into the kitchen and then into our shop, all AI-generated video, no visible cuts."
15
+ why: "The core job in English: ordered scenes, one continuous flight, generated clips that must chain without seams."
16
+
17
+ - prompt: "Our scroll-scrubbed hero video works on localhost but in production it is stuck on the first frame whatever I scroll."
18
+ why: "Non-obvious symptom: a host that ignores byte-range requests leaves the streamed video unseekable. The fix is the engine's Blob loading."
19
+
20
+ - prompt: "Every time the scroll passes from one AI clip to the next there's a jump: the kitchen at the end of the transition doesn't look like the kitchen at the start of the next clip."
21
+ why: "Non-obvious symptom of the seam law: the transition ended on the original still instead of the next clip's real first frame."
22
+
23
+ - prompt: "On iPhone our scroll video is black until I touch the screen, and on Android a fast flick freezes it."
24
+ why: "Phone failures specific to scrubbing: iOS paints nothing for a never-played video; queued seeks on a long-GOP master freeze the decoder."
25
+
26
+ - prompt: "Convierte la web de la inmobiliaria en un recorrido: al bajar, la cámara entra por la puerta, cruza el salón y sale a la terraza, todo con imágenes generadas."
27
+ why: "Photoreal walkthrough phrased as a tour, not as 'scroll video': it needs the forward-take chain shape and frame handoffs."
28
+
29
+ - prompt: "Vull que el hero de la web sigui un vol de càmera per un món en miniatura que avança amb l'scroll."
30
+ why: "Catalan, diorama look: the dive-and-hop chain shape scrubbed by scroll."
31
+
32
+ should_not_trigger:
33
+ - prompt: "On our Next.js landing page, make each section fade and rise in as it scrolls into view, and pin the pricing block while its cards slide in."
34
+ route_to: design
35
+ why: "Scroll-linked animation of DOM elements, with no video: CSS scroll timelines and ScrollTrigger pinning belong to design."
36
+
37
+ - prompt: "Take these five Kling clips, stitch them into one 30-second MP4, and lay a voiceover and music under them."
38
+ route_to: ai-media
39
+ why: "A finished media file assembled with ffmpeg, not a page scrubbed by scroll."
40
+
41
+ - prompt: "Write the headline, subhead and call to action for the hero of our bubble tea landing page."
42
+ route_to: landing-copy
43
+ why: "The words on the page; scroll-film hands its copy slots to landing-copy."
44
+
45
+ - prompt: "Generate a photorealistic product image of our bottle on a marble counter for the homepage."
46
+ route_to: replicate-images
47
+ why: "One still image, no chain and no scrubbing."
48
+
49
+ - prompt: "Our homepage LCP is 4.8 seconds because of a 6 MB hero image. How do we get it under 2.5?"
50
+ route_to: performance
51
+ why: "A Core Web Vitals fix from a measurement; nothing about scrubbed video."
52
+
53
+ - prompt: "How do I submit a video job to fal's queue and verify the webhook signature when it finishes?"
54
+ route_to: fal
55
+ why: "Provider call mechanics; scroll-film only defines what the clip must be and delegates the call."
56
+
57
+ - prompt: "Render an animated explainer from our monthly metrics JSON in React and export it as an MP4."
58
+ route_to: remotion-video
59
+ why: "A video rendered from code and data, not a generated chain scrubbed on a page."
60
+
61
+ capability:
62
+ - scenario: "A boutique hotel wants its homepage hero to be a photoreal scroll-through: lobby, then a guest room, then the rooftop pool, then the restaurant, on desktop and phones. Money is tight. Their image-to-video endpoint on fal accepts a prompt, an image_url and an end_image_url, and its page lists a price per second of video. Nothing has been generated yet. Write the plan an engineer will follow, from the first question to the last check, including the commands for the steps that need them."
63
+ must_include:
64
+ - "Prices the chain before anything renders — the clip count follows from the chosen shape (one leg per scene for a forward take, doubled by a native 9:16 phone chain) times seconds times the provider's current price, plus re-roll headroom — and waits for the client's approval"
65
+ - "Chooses a continuous forward take for this photoreal walkthrough, and says that a pull-out-and-fly-over transition would reverse the camera at every seam and read as a rewind"
66
+ - "Starts each leg from the previous leg's actual rendered last frame, extracted from the video file, and gives legs no end frame"
67
+ - "Extracts the real last frame (decoding to the end, e.g. -update 1) rather than an approximate seek a few frames from the end"
68
+ - "Probes the endpoint with one cheap clip before batching, checking that frame 0 reproduces the start image rather than re-rendering the scene"
69
+ - "Keeps one model for the whole chain, and plans for the rooftop pool clip tripping a content filter (re-roll, neutral wording) instead of switching the whole chain"
70
+ - "Encodes for seeking: a keyframe every few frames (about 8 on desktop, about 4 on phones), no audio track, faststart, native resolution"
71
+ - "Renders a native 9:16 chain for phones, or labels a centre crop as a stopgap if the budget cannot cover it"
72
+ - "Loads clips so seeking works on hosts without byte-range support (as Blobs), keeps the poster until a frame paints for iOS, and falls back to stills under reduced motion"
73
+ - "Verifies every seam by comparing the two boundary frames and by scrolling across it, including a throttled phone emulation"
74
+
75
+ - scenario: "A team built their own scroll video: a single <video src='hero.mp4' muted> whose currentTime is set to scrollProgress * duration inside a scroll event listener. It worked in development. In production behind nginx the video never moves from the first frame, on iPhone it shows black until the user taps, and on a mid-range Android a fast flick freezes it for seconds. The MP4 is a 1080p export from an editor with default settings and an audio track. Diagnose each symptom and give the fixes."
76
+ must_include:
77
+ - "Attributes the stuck first frame to the server not honouring byte-range requests (video.seekable stuck at [0, 0]) and fixes it by loading the file as a Blob (or by enabling range support), not by re-encoding alone"
78
+ - "Attributes the Android freeze to seeks queued while the decoder is still seeking plus a long keyframe interval, and fixes both: one seek in flight (skip while video.seeking) and a re-encode with a short GOP"
79
+ - "Attributes the black iPhone video to iOS not painting a muted video that never played, and fixes it with the poster kept until a frame paints plus a muted play/pause prime (muted and playsinline attributes kept)"
80
+ - "Re-encodes with no audio track and faststart, at a keyframe every few frames"
81
+ - "Moves the per-frame work out of the scroll handler: a passive listener that only requests an animation frame"