luckiest-co 1.0.13 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/luckiest-design-website/ATTRIBUTION.md +38 -0
  5. package/skills/luckiest-design-website/CHANGELOG.md +104 -0
  6. package/skills/luckiest-design-website/LICENSE +21 -0
  7. package/skills/luckiest-design-website/SKILL.md +372 -0
  8. package/skills/luckiest-design-website/UPSTREAM-CHANGELOG.md +526 -0
  9. package/skills/luckiest-design-website/engine/scrollcraft.css +432 -0
  10. package/skills/luckiest-design-website/engine/scrollcraft.js +1167 -0
  11. package/skills/luckiest-design-website/evals/evals.json +33 -0
  12. package/skills/luckiest-design-website/references/assets.md +286 -0
  13. package/skills/luckiest-design-website/references/device-diag.html +214 -0
  14. package/skills/luckiest-design-website/references/devices.md +466 -0
  15. package/skills/luckiest-design-website/references/feel.md +277 -0
  16. package/skills/luckiest-design-website/references/registry-examples.md +305 -0
  17. package/skills/luckiest-design-website/references/taste.md +304 -0
  18. package/skills/luckiest-design-website/references/template.html +138 -0
  19. package/skills/luckiest-design-website/references/uniqueness.md +480 -0
  20. package/skills/luckiest-design-website/references/verify.md +381 -0
  21. package/skills/luckiest-design-website/references/worldflight.md +349 -0
  22. package/skills/luckiest-design-website/references/worlds.md +178 -0
  23. package/skills/luckiest-design-website/scripts/doctor.mjs +177 -0
  24. package/skills/luckiest-design-website/scripts/encode.sh +80 -0
  25. package/skills/luckiest-design-website/scripts/kie.mjs +202 -0
  26. package/skills/luckiest-design-website/scripts/serve.mjs +52 -0
  27. package/skills/luckiest-design-website/scripts/shoot.mjs +644 -0
  28. package/skills/luckiest-design-website/scripts/workspace.mjs +106 -0
  29. package/skills/luckiest-design-website/scripts/worldflight-assert.mjs +273 -0
  30. package/skills/luckiest-design-website/templates/FINGERPRINTS.md +65 -0
@@ -0,0 +1,33 @@
1
+ {
2
+ "skill_name": "luckiest-design-website",
3
+ "evals": [
4
+ {
5
+ "id": 1,
6
+ "prompt": "Build me a scroll-driven landing page for my cold brew brand. I have product photos and a 20 second clip of the pour. Budget for AI assets is zero.",
7
+ "expected_output": "Runs the nine-question interview before generating anything and writes BRIEF.md verbatim. Because the spend cap is zero, routes to own-assets only, grades and encodes the pour clip with encode.sh, and uses CSS scroll-timeline for lightweight devices. Picks a grammar from all eight and says why the others lost, invents a signature move, runs the fingerprint gate, writes the feeling curve before the score table, and names one peak. Runs luckiest-copywriting for act headlines and the CTA label. Verifies with shoot.mjs at desktop, mobile and reduced-motion, reports INP and LCP, and calls report_usage once.",
8
+ "assertions": [
9
+ "Asks the interview questions before generating any asset",
10
+ "Writes BRIEF.md with feeling curve, peak, and tell-someone sentence",
11
+ "Uses no generated assets because the cap is zero",
12
+ "Names the grammar and why the other seven lost",
13
+ "Runs the fingerprint gate against the registry",
14
+ "Runs the shoot harness at desktop, mobile and reduced-motion",
15
+ "Reports INP and LCP readings or says they were not measured",
16
+ "Calls report_usage once with listing_id luckiest-design-website"
17
+ ],
18
+ "files": []
19
+ },
20
+ {
21
+ "id": 2,
22
+ "prompt": "My landing page looks AI-made. Rebuild it as an experience. Site is https://example.com and I have no brand kit.",
23
+ "expected_output": "Recognises the trigger and runs luckiest-extract-design-system on the URL to seed tokens. Interviews the human. Applies the hard rules against AI-slop tells: no uniform radius, no Inter as display face, no gradient text, no scroll cue, no counters. Offers, never auto-posts, the share-with-tribe hook after the report.",
24
+ "assertions": [
25
+ "Runs luckiest-extract-design-system on the URL before theming",
26
+ "Interviews before building",
27
+ "Refuses the AI-slop tells listed in the hard rules",
28
+ "Share-with-tribe is an offer, not an automatic post"
29
+ ],
30
+ "files": []
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,286 @@
1
+ # Assets
2
+
3
+ Generation through kie.ai, then encoding for scrubbing. All of it via
4
+ `scripts/kie.mjs` and `scripts/encode.sh`.
5
+
6
+ ```bash
7
+ node <skill>/scripts/kie.mjs probe # credit check first
8
+ node <skill>/scripts/kie.mjs still "<prompt>" out/01.png --ar 16:9 [--ref brand.png]
9
+ node <skill>/scripts/kie.mjs shot "<move>" out/01.png out/01.mp4 --dur 5 [--tail end.png]
10
+ bash <skill>/scripts/encode.sh out/01.mp4 assets/01.mp4
11
+ bash <skill>/scripts/encode.sh out/01.mp4 assets/01-m.mp4 mobile
12
+ ```
13
+
14
+ Models: `seedream/5-pro-text-to-image` (and `-image-to-image` when you pass
15
+ `--ref`) for stills, `kling/v2-1-pro` for camera moves. `aspect_ratio`,
16
+ `quality` and `output_format` are all required by seedream; omitting any one
17
+ returns a bare "This field is required" that does not name the field.
18
+
19
+ **seedream rejects most aspect ratios, and does not say which it takes.** An
20
+ unsupported value fails at `createTask` with `"This aspect_ratio is not within
21
+ the range of allowed options"` and no list, which reads like a malformed request
22
+ rather than a menu problem. Nothing is charged, so the cost is a wasted round
23
+ trip and the time to work out that the string itself was fine.
24
+
25
+ | `--ar` | Status | Returns |
26
+ |---|---|---|
27
+ | `16:9` | works | 2736x1520 |
28
+ | `9:16` | works | 1520x2736 |
29
+ | `3:4` | works | 1776x2352 |
30
+ | `4:5` | **rejected** | n/a |
31
+
32
+ The returned pixel dimensions are near the ratio rather than exactly it, so
33
+ derive layout from the file, not from the string you asked for. Treat the table
34
+ as the verified set rather than as the whole allowed list: **use a value from it,
35
+ and if you need another, send one throwaway call before writing a wave of
36
+ prompts around it.** `4:5` is the one that catches people, because it is the
37
+ standard portrait social ratio and every other generator takes it. `3:4` is the
38
+ portrait to reach for here.
39
+
40
+ ---
41
+
42
+ ## How many assets
43
+
44
+ Fewer than you think. A six-act page needs roughly:
45
+
46
+ - **2 clips.** One hero move, one texture or detail move. That is the cap from
47
+ SKILL.md, and it is a quality rule as much as a budget one.
48
+ - **4 to 6 stills.** Posters for the clips, plus whatever the flow and rail acts
49
+ show.
50
+
51
+ Every clip also needs a poster, and the poster must be **the clip's own first
52
+ frame**, not a separate generation. Pull it with ffmpeg rather than generating
53
+ a lookalike; a poster that does not match causes a visible jump the moment the
54
+ video paints.
55
+
56
+ ```bash
57
+ ffmpeg -y -i out/01.mp4 -frames:v 1 -q:v 2 assets/01-poster.png
58
+ ```
59
+
60
+ **Use the same ffmpeg `encode.sh` resolved, not bare `ffmpeg`.** `encode.sh`
61
+ goes looking for a full build precisely because the one on PATH may be stripped,
62
+ but this poster line and the PSNR seam check below both call `ffmpeg` directly.
63
+ On a stripped build the WebP muxer is missing and you get `Unable to choose an
64
+ output format for 'poster.webp'`, which reads like a bad filename rather than a
65
+ missing encoder, and `-lavfi psnr` fails with `No such filter: 'psnr'`. Resolve
66
+ it once and reuse it:
67
+
68
+ ```bash
69
+ FF=$(ls -d "$HOME"/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_*/ffmpeg-*-full_build/bin/ffmpeg.exe 2>/dev/null | head -1)
70
+ "$FF" -y -i assets/01.mp4 -frames:v 1 -vf scale=1600:-2 -c:v libwebp -quality 82 assets/p01.webp
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Stills
76
+
77
+ 1. Pick a world in [worlds.md](worlds.md) and write its preamble once.
78
+ 2. Every prompt is: **preamble, blank line, scene**. Verbatim preamble, every
79
+ time. This is the single thing that makes separately generated images look
80
+ like one shoot.
81
+ 3. Name where the empty space goes, because copy sits on these images.
82
+ 4. **Read every PNG before using it.** Generation is cheap; shipping a bad frame
83
+ is not.
84
+
85
+ **Pass the real brand object as `--ref`.** For anything with packaging, a logo,
86
+ or a product, hand seedream the actual asset. A label that drifts between shots
87
+ is the first thing a client notices. It holds up remarkably well: the same can
88
+ reads correctly across a dark kitchen, a hard-light flat-lay and a studio
89
+ backdrop when the cutout is passed every time.
90
+
91
+ ---
92
+
93
+ **Published unit costs** (kie.ai, as of August 2026): `seedream/5-pro` still =
94
+ **28 credits**, `kling/v2-1-pro` 5s clip = **160 credits**. A seven-still,
95
+ two-clip page is therefore around 520 credits. `kie.mjs probe` reports a balance,
96
+ not a delta, so plan the spend from these numbers and probe before and after to
97
+ confirm. Do not trust a probe delta as this build's cost if anything else is
98
+ running against the same key (see [verify.md](verify.md)).
99
+
100
+ **Plan against those rates; expect the ledger to debit roughly 0.4x of them.**
101
+ Two reconciliations against the account with no other consumer put actual debits
102
+ at about 38% of the per-call sum: 1447 summed against 530 debited, and 2252
103
+ summed against 856 debited. That puts a still nearer ~11 credits and a 5s kling
104
+ clip nearer ~61, though those are back-derived from two samples rather than
105
+ published, so do not quote them as rates.
106
+
107
+ The practical consequence: **every budget cap in this skill is conservative, and
108
+ a build that comes in at its per-call cap has real headroom.** Keep planning at
109
+ 28 and 160, because a ceiling that never under-claims is the right one to hold a
110
+ build to, and because the ratio is an observation about billing rather than a
111
+ published price that will hold. But do not refuse a justified reroll on a cap
112
+ computed at the documented rates, and do not report a per-call sum as measured
113
+ spend.
114
+
115
+ ---
116
+
117
+ ## Real footage the client already has
118
+
119
+ Supplied footage is usually **flat**: shot log-ish or picture-profiled, with no
120
+ white point. Downscaled straight into the page it produces washed-out acts that
121
+ no amount of scrim tuning rescues, and the instinct to fix it with a CSS filter
122
+ over full-bleed media is the thing taste.md warns against, because a filter
123
+ flattens the whole frame.
124
+
125
+ Grade it into a pre-encode intermediate instead:
126
+
127
+ ```bash
128
+ # 1. measure. YMIN/YMAX/YAVG tell you whether the clip ever reaches white
129
+ ffmpeg -i raw.mov -vf signalstats,metadata=print:key=lavfi.signalstats.YMAX -f null -
130
+
131
+ # 2. expand levels + a small saturation lift, and land the fps you want
132
+ ffmpeg -y -i raw.mov -vf "colorlevels=rimin=0.09:gimin=0.09:bimin=0.09:\
133
+ rimax=0.64:gimax=0.64:bimax=0.64,eq=saturation=1.08,fps=30,scale=1920:-2" \
134
+ -c:v libx264 -crf 16 -an graded.mp4
135
+
136
+ # 3. then the normal dense-GOP encode for scrubbing
137
+ bash <skill>/scripts/encode.sh graded.mp4 assets/01.mp4
138
+ ```
139
+
140
+ Three more things real footage needs that generated clips do not:
141
+
142
+ - **Trim before anyone enters frame.** "Nothing enters or leaves" is a hard
143
+ requirement for a clip the reader can park anywhere in, and real recordings
144
+ routinely have someone walk through at second nine.
145
+ - **Target 24 to 30fps before `encode.sh`.** A 60fps phone clip at a dense GOP is
146
+ several times the size for motion no hand can resolve. Decimate with `fps=30`
147
+ in the intermediate; the dense keyframes then cost half as much.
148
+ - **Re-encode, never stream-copy.** Cuts made with `-c copy` decode badly under
149
+ a scrubber.
150
+
151
+ ---
152
+
153
+ ## Camera moves
154
+
155
+ `shot` takes a still and moves the camera through it.
156
+
157
+ What makes a clip scrub well is not the same as what makes it watch well:
158
+
159
+ - **One continuous move, one direction.** A dolly-in, a drift down, a slow
160
+ orbit. Any cut, snap or direction reversal becomes a jolt under the wheel,
161
+ because the reader controls the playhead and will sit on the reversal.
162
+ - **Slower than feels right when previewed.** The move is spread over two or
163
+ three viewport-heights of scroll. A move that looks sedate at 24fps feels
164
+ correct under a hand.
165
+ - **The subject stays in frame throughout.** The reader may park anywhere.
166
+ - **Nothing enters or leaves.** A person walking in is a different shot at
167
+ frame 1 and frame 120, and the poster will match neither.
168
+
169
+ Prompt shape: what continues, how the camera moves, then the negatives.
170
+
171
+ > The camera pushes slowly and steadily forward toward the can, a smooth
172
+ > continuous dolly-in with a very slight downward tilt. The blurred window
173
+ > slides past on the left as parallax. The can stays perfectly still and in
174
+ > frame throughout. One single continuous take, no cuts, no camera shake, no
175
+ > zoom snap. Slow, cinematic, controlled.
176
+
177
+ The script already sends a negative prompt covering judder, warping, morphing,
178
+ flicker and scene changes, which are the failure modes that specifically wreck a
179
+ scrub.
180
+
181
+ ### Seam locking, if you actually need a chain
182
+
183
+ `--tail` pins the last frame as well as the first. Leg N's tail is leg N+1's
184
+ head, so the joint is frame-identical:
185
+
186
+ ```bash
187
+ ffmpeg -y -sseof -0.05 -i leg1.mp4 -frames:v 1 -q:v 2 seam1.png
188
+ node kie.mjs shot "<move>" seam1.png leg2.mp4 --dur 5
189
+ ```
190
+
191
+ Extract the seam frame from the **encoded** clip, not the source: re-encoding
192
+ shifts frames slightly, and a seam built from the wrong file is a one-frame pop.
193
+
194
+ **Chaining on pre-generated anchors makes the legs parallel.** Extracting each
195
+ leg's head from the previous leg's *encoded* file forces the whole flight to
196
+ generate serially, which is roughly 45 minutes for ten legs. Generating every
197
+ anchor still first, then giving leg N `--tail` of anchor N+1 and head of anchor
198
+ N, means all ten clips run at once and every joint is still frame-locked to an
199
+ image both sides were built from. `orrery` did this for a ten-leg flight and
200
+ measured 28.5-39.8 dB across all nine joints, inside the band the serial
201
+ `descent` chain shipped at. The cost is that you must author the anchors as a
202
+ coherent descent, because the model resolves a head-and-tail conflict by pulling
203
+ the camera back.
204
+
205
+ Most pages should not do this at all. A chain exists only to hide cuts between
206
+ scenes, and varying the device between acts removes the cut instead of hiding
207
+ it, for free and with no failure mode. Chain only when the brief is literally
208
+ "one continuous journey".
209
+
210
+ ---
211
+
212
+ ## Encoding
213
+
214
+ `encode.sh` sets a dense GOP (`-g 8` desktop, `-g 4` mobile), strips audio, and
215
+ adds `+faststart`.
216
+
217
+ The reason is the whole trick: **a normal web encode places a keyframe every two
218
+ to five seconds.** Seeking to an arbitrary time makes the decoder walk forward
219
+ from the previous keyframe, so a sparse-GOP file plays perfectly and scrubs like
220
+ mud. Dense keyframes cost file size and buy responsiveness.
221
+
222
+ Expect roughly 3MB for a 5s 1080p desktop clip and 1.5MB for the 720p mobile
223
+ one. Two clips is about 9MB of video on the page, and the engine fetches each
224
+ only as its act approaches.
225
+
226
+ **Grain-heavy worlds run about double that.** Documentary grain, moving foliage
227
+ and film texture gave 5 to 6MB per desktop clip at the script's `-crf 20`;
228
+ `-crf 22` is a reasonable dial if a page needs the megabytes back. Longer real
229
+ footage costs proportionally more, so a page carrying 15 seconds of supplied
230
+ video will sit above the 9MB guideline. That can be the right trade, but make it
231
+ deliberately rather than discovering it at the end.
232
+
233
+ Audio is stripped because these clips are scrubbed, never played. A muted track
234
+ is dead weight and an autoplay-policy hazard.
235
+
236
+ **A stripped ffmpeg will fail here.** Some toolchains put an ffmpeg on PATH with
237
+ about 50 filters and no `scale`, `fps` or `tile`. It reports `No option name
238
+ near ...`, which reads like a syntax error in your command rather than a missing
239
+ filter. `encode.sh` counts filters and goes looking for a real build; override
240
+ with `SCROLLCRAFT_FFMPEG`.
241
+
242
+ ---
243
+
244
+ ## Portrait
245
+
246
+ A 16:9 clip covering a 9:16 viewport crops to the middle third, and a
247
+ composition built around negative space on the left loses exactly that space.
248
+
249
+ Options, in order of cost:
250
+
251
+ 1. **Compose for both.** Keep the subject in the centre third and the copy in a
252
+ bottom band rather than in side negative space, because the bottom band
253
+ survives a centre crop and side space does not. Cheapest, and usually enough.
254
+ 2. **Native portrait renders.** Generate a 9:16 still and clip for the hero only,
255
+ wire them as `data-sc-src-mobile`, and swap the poster with `<picture>` so the
256
+ frame-holder matches the clip it is holding for:
257
+
258
+ ```html
259
+ <picture>
260
+ <source media="(max-width: 860px)" srcset="assets/01-hero-p.webp">
261
+ <img class="sc-stage__poster" src="assets/01-hero.webp" alt="">
262
+ </picture>
263
+ <video data-sc-scrub data-sc-src="assets/01.mp4"
264
+ data-sc-src-mobile="assets/01-p.mp4" playsinline muted></video>
265
+ ```
266
+
267
+ A portrait poster with a landscape clip (or the reverse) jumps visibly the
268
+ moment the video paints, which is the whole failure the poster exists to
269
+ prevent. `encode.sh` has no portrait mode; do it by hand:
270
+
271
+ ```bash
272
+ ffmpeg -y -i src.mp4 -vf "scale=720:-2" -c:v libx264 -crf 20 -g 4 \
273
+ -pix_fmt yuv420p -an -movflags +faststart assets/01-p.mp4
274
+ # cropping a 16:9 source to 9:16 instead of rendering native:
275
+ # -vf "crop=ih*9/16:ih,scale=720:-2"
276
+ ```
277
+ 3. **Drop the clip on phones.** Serve the poster and let the copy carry the act.
278
+ Under reduced motion the engine already does exactly this, so the layout is
279
+ known to work.
280
+
281
+ Also step the hero display size down on phones. `--sc-t-4xl` floors at 3.4rem,
282
+ which is a desktop floor: it wraps a normal hero headline to six lines at 390px.
283
+ See [taste.md](taste.md).
284
+
285
+ Do not solve it with `object-fit: contain`. Letterboxed video on a landing page
286
+ reads as a broken embed.
@@ -0,0 +1,214 @@
1
+ <!doctype html>
2
+ <!--
3
+ device-diag: real-device scrub diagnostic for luckiest-design-website builds.
4
+
5
+ Headless verification cannot reproduce a phone's video decoder, autoplay
6
+ policy, Low Power Mode, or touch scrolling. When a clip is reported frozen
7
+ on a real device, deploy this page beside the site (same origin as the
8
+ clips), open it on the device, scroll up and down a few times, and read the
9
+ verdicts. One screenshot isolates the failing layer:
10
+
11
+ suspect-blob FROZEN, suspect-direct MOVING -> the blob: URL path is the problem
12
+ suspect FROZEN, known-good MOVING -> something specific to that clip's lifecycle
13
+ all MOVING -> the video is fine; the fault is in the
14
+ engine's reveal/tick path on the real page
15
+ all FROZEN -> the device refuses scrub decode; use the
16
+ poster fallback for that act
17
+
18
+ To use: edit the TESTS array below. Point the first two entries at the
19
+ suspect clip (same file, loaded two ways) and the third at a clip known to
20
+ work on the device. Paths resolve against this page's URL.
21
+
22
+ Scrubbing is driven by a rAF poll of scrollY, not scroll events, because a
23
+ driven browser can move the page without firing them.
24
+ -->
25
+ <html lang="en">
26
+ <head>
27
+ <meta charset="utf-8">
28
+ <meta name="viewport" content="width=device-width, initial-scale=1">
29
+ <meta name="robots" content="noindex, nofollow">
30
+ <title>scrub diagnostic</title>
31
+ <style>
32
+ * { margin: 0; box-sizing: border-box; }
33
+ html { background: #0b0f14; color: #e8edf2; font: 13px/1.45 ui-monospace, Menlo, Consolas, monospace; }
34
+ body { height: 520vh; }
35
+ .panel { position: fixed; inset: 0; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
36
+ h1 { font-size: 14px; font-weight: 600; }
37
+ .hint { color: #8fa0b3; }
38
+ .row { display: flex; gap: 8px; flex: 0 0 auto; }
39
+ .cell { flex: 1 1 0; min-width: 0; }
40
+ .cell video { width: 100%; aspect-ratio: 9 / 16; object-fit: cover; background: #000; display: block; border: 1px solid #26303c; border-radius: 6px; }
41
+ .name { font-weight: 600; margin: 6px 0 2px; }
42
+ .verdict { font-size: 15px; font-weight: 700; padding: 2px 6px; border-radius: 4px; display: inline-block; }
43
+ .wait { background: #26303c; }
44
+ .ok { background: #0f5132; color: #b6f0cd; }
45
+ .bad { background: #6b1220; color: #ffc2cd; }
46
+ .stats { color: #aab7c4; font-size: 11.5px; white-space: pre-line; }
47
+ #meta { color: #8fa0b3; font-size: 11.5px; }
48
+ #log { flex: 1 1 auto; overflow: auto; background: #10161d; border: 1px solid #26303c; border-radius: 6px; padding: 8px; font-size: 11px; color: #9fb4c8; white-space: pre-wrap; }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <div class="panel">
53
+ <h1>scrub diagnostic — SCROLL UP AND DOWN A FEW TIMES, then read the verdicts</h1>
54
+ <div class="hint">A: suspect clip, blob URL (how the engine loads it) &nbsp; B: suspect clip, direct file &nbsp; C: a clip that works on this device, blob URL</div>
55
+ <div class="row" id="cells"></div>
56
+ <div id="meta"></div>
57
+ <div id="log"></div>
58
+ </div>
59
+ <script>
60
+ (function () {
61
+ 'use strict';
62
+
63
+ // EDIT THESE for the build under test.
64
+ var SUSPECT = 'assets/01-suspect-m.mp4';
65
+ var KNOWN_GOOD = 'assets/02-known-good-m.mp4';
66
+ var TESTS = [
67
+ { key: 'A', label: 'A suspect blob', src: SUSPECT, mode: 'blob' },
68
+ { key: 'B', label: 'B suspect direct', src: SUSPECT, mode: 'direct' },
69
+ { key: 'C', label: 'C known-good blob', src: KNOWN_GOOD, mode: 'blob' }
70
+ ];
71
+
72
+ var logEl = document.getElementById('log');
73
+ function log(m) {
74
+ logEl.textContent += m + '\n';
75
+ logEl.scrollTop = logEl.scrollHeight;
76
+ }
77
+ window.addEventListener('error', function (e) { log('JS ERROR: ' + e.message); });
78
+
79
+ var maxScrolled = 0;
80
+ var cellsEl = document.getElementById('cells');
81
+ var canvas = document.createElement('canvas');
82
+ canvas.width = 16; canvas.height = 16;
83
+ var ctx = canvas.getContext('2d', { willReadFrequently: true });
84
+
85
+ function make(t) {
86
+ var cell = document.createElement('div');
87
+ cell.className = 'cell';
88
+ cell.innerHTML = '<div class="name">' + t.label + '</div>' +
89
+ '<span class="verdict wait" id="v' + t.key + '">…</span>' +
90
+ '<video muted playsinline preload="auto" id="el' + t.key + '"></video>' +
91
+ '<div class="stats" id="s' + t.key + '"></div>';
92
+ cellsEl.appendChild(cell);
93
+
94
+ var T = {
95
+ key: t.key, el: cell.querySelector('video'),
96
+ verdict: cell.querySelector('.verdict'),
97
+ stats: cell.querySelector('.stats'),
98
+ seeked: 0, hashes: {}, hashCount: 0, prime: 'not tried',
99
+ primed: false, priming: false, err: ''
100
+ };
101
+ T.el.muted = true; T.el.playsInline = true;
102
+
103
+ T.el.addEventListener('seeked', function () { T.seeked++; });
104
+ T.el.addEventListener('error', function () {
105
+ var e = T.el.error;
106
+ T.err = 'MediaError code ' + (e ? e.code : '?');
107
+ log(t.key + ': ' + T.err);
108
+ });
109
+ T.el.addEventListener('loadedmetadata', function () {
110
+ log(t.key + ': metadata, duration ' + T.el.duration.toFixed(2) + 's');
111
+ try { T.el.currentTime = 0.001; } catch (e) {}
112
+ prime(T, 'load'); // same as the engine: try with no gesture first
113
+ });
114
+
115
+ if (t.mode === 'blob') {
116
+ fetch(t.src)
117
+ .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.blob(); })
118
+ .then(function (b) {
119
+ log(t.key + ': fetched ' + (b.size / 1048576).toFixed(2) + ' MB, blob URL set');
120
+ T.el.src = URL.createObjectURL(b);
121
+ })
122
+ .catch(function (e) { T.err = 'fetch failed: ' + e.message; log(t.key + ': ' + T.err); });
123
+ } else {
124
+ T.el.src = t.src;
125
+ log(t.key + ': direct src set');
126
+ }
127
+ return T;
128
+ }
129
+
130
+ function prime(T, why) {
131
+ if (T.primed || T.priming || !T.el.src) return;
132
+ T.priming = true;
133
+ var pr;
134
+ try { pr = T.el.play(); } catch (e) { T.priming = false; T.prime = 'threw (' + why + ')'; return; }
135
+ setTimeout(function () {
136
+ if (T.priming) { T.priming = false; if (T.prime === 'not tried') T.prime = 'play() never settled (' + why + ')'; }
137
+ }, 2000);
138
+ if (pr && pr.then) {
139
+ pr.then(function () {
140
+ T.priming = false; T.primed = true; T.prime = 'OK (' + why + ')';
141
+ try { T.el.pause(); } catch (e) {}
142
+ log(T.key + ': primed via ' + why);
143
+ }, function (e) {
144
+ T.priming = false; T.prime = (e && e.name ? e.name : 'rejected') + ' (' + why + ')';
145
+ log(T.key + ': play() rejected via ' + why + ': ' + (e && e.name));
146
+ });
147
+ } else { T.priming = false; T.primed = true; T.prime = 'OK sync (' + why + ')'; try { T.el.pause(); } catch (e) {} }
148
+ }
149
+
150
+ var tests = TESTS.map(make);
151
+
152
+ function primeAll(ev) { tests.forEach(function (T) { prime(T, ev); }); }
153
+ ['touchstart', 'touchend', 'pointerdown', 'click', 'scroll'].forEach(function (ev) {
154
+ addEventListener(ev, function () { primeAll(ev); }, { passive: true });
155
+ });
156
+
157
+ // scroll position drives every clip, exactly like the engine. Polled from a
158
+ // rAF loop rather than a scroll listener, so the diagnostic cannot be blinded
159
+ // by an environment that moves the page without firing scroll events.
160
+ var lastY = -1, lastPrimeTry = 0;
161
+ function loop() {
162
+ var maxY = document.body.scrollHeight - innerHeight;
163
+ var p = maxY > 0 ? Math.max(0, Math.min(1, scrollY / maxY)) : 0;
164
+ if (scrollY !== lastY) {
165
+ if (lastY !== -1 && performance.now() - lastPrimeTry > 600) {
166
+ lastPrimeTry = performance.now();
167
+ primeAll('scrollmove');
168
+ }
169
+ lastY = scrollY;
170
+ maxScrolled = Math.max(maxScrolled, p);
171
+ }
172
+ tests.forEach(function (T) {
173
+ if (!T.el.duration || T.el.seeking) return;
174
+ var t = p * T.el.duration * 0.98;
175
+ if (Math.abs(T.el.currentTime - t) > 0.02) {
176
+ try { T.el.currentTime = t; } catch (e) {}
177
+ }
178
+ });
179
+ requestAnimationFrame(loop);
180
+ }
181
+ requestAnimationFrame(loop);
182
+
183
+ function frameHash(T) {
184
+ if (T.el.readyState < 2) return null;
185
+ try {
186
+ ctx.drawImage(T.el, 0, 0, 16, 16);
187
+ var d = ctx.getImageData(0, 0, 16, 16).data, h = 0;
188
+ for (var i = 0; i < d.length; i += 7) h = (h * 31 + d[i]) >>> 0;
189
+ return h;
190
+ } catch (e) { return null; }
191
+ }
192
+
193
+ setInterval(function () {
194
+ tests.forEach(function (T) {
195
+ var h = frameHash(T);
196
+ if (h !== null && !T.hashes[h]) { T.hashes[h] = 1; T.hashCount++; }
197
+ T.stats.textContent =
198
+ 'prime: ' + T.prime +
199
+ '\nreadyState ' + T.el.readyState + ' · seeks done ' + T.seeked +
200
+ '\ntime ' + T.el.currentTime.toFixed(2) + 's · frames seen ' + T.hashCount +
201
+ (T.err ? '\n' + T.err : '');
202
+ if (T.hashCount >= 3) { T.verdict.textContent = 'MOVING'; T.verdict.className = 'verdict ok'; }
203
+ else if (T.err) { T.verdict.textContent = 'ERROR'; T.verdict.className = 'verdict bad'; }
204
+ else if (maxScrolled > 0.35) { T.verdict.textContent = 'FROZEN'; T.verdict.className = 'verdict bad'; }
205
+ });
206
+ }, 250);
207
+
208
+ document.getElementById('meta').textContent =
209
+ navigator.userAgent + ' · ' + innerWidth + 'x' + innerHeight +
210
+ ' · dpr ' + devicePixelRatio;
211
+ })();
212
+ </script>
213
+ </body>
214
+ </html>