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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/luckiest-design-website/ATTRIBUTION.md +38 -0
- package/skills/luckiest-design-website/CHANGELOG.md +104 -0
- package/skills/luckiest-design-website/LICENSE +21 -0
- package/skills/luckiest-design-website/SKILL.md +372 -0
- package/skills/luckiest-design-website/UPSTREAM-CHANGELOG.md +526 -0
- package/skills/luckiest-design-website/engine/scrollcraft.css +432 -0
- package/skills/luckiest-design-website/engine/scrollcraft.js +1167 -0
- package/skills/luckiest-design-website/evals/evals.json +33 -0
- package/skills/luckiest-design-website/references/assets.md +286 -0
- package/skills/luckiest-design-website/references/device-diag.html +214 -0
- package/skills/luckiest-design-website/references/devices.md +466 -0
- package/skills/luckiest-design-website/references/feel.md +277 -0
- package/skills/luckiest-design-website/references/registry-examples.md +305 -0
- package/skills/luckiest-design-website/references/taste.md +304 -0
- package/skills/luckiest-design-website/references/template.html +138 -0
- package/skills/luckiest-design-website/references/uniqueness.md +480 -0
- package/skills/luckiest-design-website/references/verify.md +381 -0
- package/skills/luckiest-design-website/references/worldflight.md +349 -0
- package/skills/luckiest-design-website/references/worlds.md +178 -0
- package/skills/luckiest-design-website/scripts/doctor.mjs +177 -0
- package/skills/luckiest-design-website/scripts/encode.sh +80 -0
- package/skills/luckiest-design-website/scripts/kie.mjs +202 -0
- package/skills/luckiest-design-website/scripts/serve.mjs +52 -0
- package/skills/luckiest-design-website/scripts/shoot.mjs +644 -0
- package/skills/luckiest-design-website/scripts/workspace.mjs +106 -0
- package/skills/luckiest-design-website/scripts/worldflight-assert.mjs +273 -0
- 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) B: suspect clip, direct file 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>
|