-
Notifications
You must be signed in to change notification settings - Fork 102
feat(website): rebuild the landing page around a live editor #391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
51 commits
Select commit
Hold shift + click to select a range
d76672b
feat(website): show the real editor instead of a drawing of it
EtienneLescot ff9bb28
feat(website): let the reader drive the picture with the scroll wheel
EtienneLescot cdc9428
fix(website): the scrubbed clip was invisible in production only
EtienneLescot 5084e21
feat(website): reshoot the walkthrough — no stranger's face, no price…
EtienneLescot 8be00ef
feat(website): the editor, redrawn live and walked through by the scroll
EtienneLescot 280e4fd
docs(recreation): write the art direction the build was missing
EtienneLescot 3813023
feat(website): six settings, two acts, one clock
EtienneLescot 9f0e44b
feat(website): build the landing page around the editor
EtienneLescot 2f82bbc
feat(website): ship the app's real cursor packs, for v4
EtienneLescot 84541cb
feat(website): add the webcam clip the composite's bubble plays
EtienneLescot 8f32e4c
feat(website): port the design's v4 recreation
EtienneLescot e9604e2
fix(website): let the webcam play, instead of animating it
EtienneLescot ee58952
feat(website): the design's hero
EtienneLescot ab1bfa8
fix(website): seat the clips on the floor, and close the join
EtienneLescot 9d00c4a
feat(website): draw the four feature panels, as the design has them
EtienneLescot 8711c4a
perf(website): stop shipping pixels and blur nobody sees
EtienneLescot c972a7a
perf(website): unpack two cursors from the stylesheet, and stop easin…
EtienneLescot 0da018c
fix(website): let the timeline arrive, and the inspector fit what is …
EtienneLescot ccf0450
fix(website): close the three holes in the score, and cut the page's …
EtienneLescot a7104a1
fix(website): give the transcript a window, then a script that matche…
EtienneLescot 1ab2890
fix(website): aim the pointer at where the target is, not where it was
EtienneLescot d3108c8
feat(website): finish the page the recording is recording
EtienneLescot 0ea1328
fix(website): make the rail's scale a function of the stage, not a co…
EtienneLescot e728d9e
feat(website): the scene, in portrait
EtienneLescot 88314b7
feat(website): give the recorded app its window back
EtienneLescot 05abece
fix(website): put the hand on the handle, and the sweep on the right …
EtienneLescot ed3d458
fix(website): the captions line was quoting the wrong thirteen
EtienneLescot 6b370f6
fix(website): the hero says who it is, and the recorded pointer points
EtienneLescot 49ec047
fix(website): make the scroll hint a signal, and give it a screen to …
EtienneLescot daa0075
fix(website): bring the title in with the panel it announces
EtienneLescot 6a45aae
fix(website): stop reserving screen-wide height for a left-column cap…
EtienneLescot 4b0ddaa
fix(website): hang the inspector from the caption again, not from the…
EtienneLescot e948811
fix(website): make the left column one thing, so it can be aligned wi…
EtienneLescot cecf0b4
refactor(website): one box for both layouts, instead of two kept in s…
EtienneLescot 1857e21
fix(website): stop the measuring pass flashing a caption on resize
EtienneLescot a3e3912
fix(website): the palette hangs where the inspector hangs
EtienneLescot a50b570
fix(website): centre the column on what is visible, not on what is la…
EtienneLescot a330456
fix(website): hand the column over without a gap, and let portrait si…
EtienneLescot 429fc33
fix(website): let the transcript fill its panel instead of guessing a…
EtienneLescot d882e21
chore(website): make the branch pass its own lint
EtienneLescot 8b92322
feat(website): aim the recorded pointer at the frame, not at coordinates
EtienneLescot dd9a06e
fix(website): change the caption with the panel it describes
EtienneLescot d22ee24
fix(website): give the scene back whole when the driver stops
EtienneLescot 18899a7
perf(website): coalesce the resize measurement into one frame
EtienneLescot 6547bdd
fix(website): read the cursor hotspots the way the app reads them
EtienneLescot e1c39b6
feat(website): end the cursor take on the green pointer
EtienneLescot ed1ad57
fix(website): make two build-time checks fail loudly instead of quietly
EtienneLescot 892e707
chore(website): keep a local account name out of the vendored document
EtienneLescot 0a107f6
docs(website): correct the counts, the gate width and the claims that…
EtienneLescot f510426
fix(website): a 404 in the structured data, a literal colour, a locke…
EtienneLescot d0e91ec
build(website): let the generator own generated.ts, and make --check …
EtienneLescot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * Guards the landing page's media budget. | ||
| * | ||
| * The walkthrough's page weight is the whole argument for shipping video at all | ||
| * — a demo that makes the page slow has argued against the product it is | ||
| * demonstrating. Byte budgets that live only in a design document drift on the | ||
| * first re-cut, so they live here and the build fails on them. | ||
| * | ||
| * Everything under website/static/ is committed to git permanently: this repo | ||
| * has no LFS filter, and video neither compresses nor deltas, so a re-cut costs | ||
| * its full size again rather than a diff. The directory ceiling is the real | ||
| * constraint; the per-file ones just localise the failure. | ||
| * | ||
| * The audio check is not a nicety. WebKit grants gesture-free autoplay to media | ||
| * that contains no audio track — the `muted` attribute alone is not enough on | ||
| * iOS — and the masters these clips are cut from carry an AAC stream of digital | ||
| * silence. Encoding without `-an` produces a clip that silently refuses to play | ||
| * for a large share of visitors, and looks perfect on the machine that made it. | ||
| */ | ||
|
|
||
| import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; | ||
| import { join, relative, resolve } from "node:path"; | ||
|
|
||
| const ROOT = resolve(import.meta.dirname, ".."); | ||
|
|
||
| const RULES = [ | ||
| { dir: "static/video", ext: [".mp4"], max: 200_000 }, | ||
| { dir: "static/img/walkthrough", ext: [".jpg", ".avif"], max: 70_000 }, | ||
| // The cursor packs, copied out of the application's own public/cursors. Each | ||
| // is a 32-logical sprite at 128px, so a five-figure file here means someone | ||
| // has shipped a full-resolution source by mistake. | ||
| { dir: "static/img/cursors", ext: [".png"], max: 12_000 }, | ||
| ]; | ||
|
|
||
| /** | ||
| * A `-scrub` clip is a different kind of file and gets a different ceiling. | ||
| * | ||
| * It is never played; scroll position seeks it. A seek into a long GOP has to | ||
| * decode from the preceding keyframe, so these are encoded all-intra — every | ||
| * frame its own keyframe — which is the entire reason they are large. Measured | ||
| * on the export beat at 960x540: 32 KB at `-g 60`, 402 KB all-intra at 30fps, | ||
| * 268 KB once dropped to 20fps. Twenty frames a second is finer than a scroll | ||
| * resolves, and it is where this ceiling was set. | ||
| * | ||
| * The generous per-file number is safe because TOTAL_MAX is the rule that | ||
| * actually protects the repository, and it did not move. | ||
| */ | ||
| const SCRUB_MAX = 300_000; | ||
| const TOTAL_MAX = 1_600_000; | ||
|
|
||
| const problems = []; | ||
| let total = 0; | ||
| let counted = 0; | ||
|
|
||
| function walk(dir) { | ||
| let entries; | ||
| try { | ||
| entries = readdirSync(dir, { withFileTypes: true }); | ||
| } catch { | ||
| return []; // an absent directory is a section not yet shot, not a failure | ||
| } | ||
| return entries.flatMap((e) => (e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)])); | ||
| } | ||
|
|
||
| /** The boxes a track's `hdlr` can be nested inside. Everything else is skipped | ||
| * whole — including `mdat`, which is the point. */ | ||
| const MP4_CONTAINERS = new Set(["moov", "trak", "mdia"]); | ||
|
|
||
| /** | ||
| * True if the MP4 declares a sound track. Reads the `hdlr` boxes rather than | ||
| * shelling out to ffprobe, which is not guaranteed on a CI runner and would make | ||
| * this check quietly skippable — which is how the guarantee would be lost. | ||
| * | ||
| * Walked as a box tree, not searched for as a byte pattern. `mdat` is compressed | ||
| * picture: the twelve bytes that spell a sound handler can occur in it by | ||
| * chance, and the failure that would produce — a silent clip rejected for an | ||
| * audio track it does not have — is unreproducible and looks like a bug in this | ||
| * file rather than in the clip. | ||
| */ | ||
| function hasAudioTrack(file) { | ||
| const buf = readFileSync(file); | ||
| const walk = (start, end) => { | ||
| let pos = start; | ||
| while (pos + 8 <= end) { | ||
| let size = buf.readUInt32BE(pos); | ||
| let head = 8; | ||
| if (size === 1) { | ||
| if (pos + 16 > end) return false; | ||
| size = Number(buf.readBigUInt64BE(pos + 8)); | ||
| head = 16; | ||
| } else if (size === 0) { | ||
| size = end - pos; // the last box, extending to the end of the file | ||
| } | ||
| // A size that runs past its parent means the file is not what it says | ||
| // it is; stop rather than resync, which is how a scan gets back into | ||
| // payload bytes. | ||
| if (size < head || pos + size > end) return false; | ||
| const type = buf.toString("latin1", pos + 4, pos + 8); | ||
| if (type === "hdlr") { | ||
| // FullBox: 4 version/flags, 4 pre_defined, then handler_type. | ||
| if (buf.toString("latin1", pos + head + 8, pos + head + 12) === "soun") return true; | ||
| } else if (MP4_CONTAINERS.has(type) && walk(pos + head, pos + size)) { | ||
| return true; | ||
| } | ||
| pos += size; | ||
| } | ||
| return false; | ||
| }; | ||
| return walk(0, buf.length); | ||
| } | ||
|
|
||
| /** | ||
| * Why an AVIF might not belong. It is offered *ahead of* the JPEG and never | ||
| * instead of it — an engine that cannot decode AVIF falls through to the same | ||
| * <picture>'s JPEG, and the schema.org screenshot points at the JPEG as well — | ||
| * so the pair only pays for itself while the AVIF is the smaller of the two. An | ||
| * encoder run that came out heavier would hand every modern browser the worse | ||
| * file, and the JPEG would still be in git behind it: two costs for no win. | ||
| */ | ||
| function avifProblem(file, bytes) { | ||
| const fallback = file.replace(/\.avif$/, ".jpg"); | ||
| if (!existsSync(fallback)) { | ||
| return "has no .jpg beside it — engines without AVIF would have nothing to fall back to"; | ||
| } | ||
| const fallbackBytes = statSync(fallback).size; | ||
| if (bytes >= fallbackBytes) { | ||
| return ( | ||
| `${bytes.toLocaleString()} B is no smaller than its ${fallbackBytes.toLocaleString()} B ` + | ||
| `JPEG. Re-encode it lower, or drop the AVIF and ship the JPEG alone.` | ||
| ); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| for (const rule of RULES) { | ||
| const abs = join(ROOT, rule.dir); | ||
| for (const file of walk(abs)) { | ||
| const rel = relative(ROOT, file); | ||
| const bytes = statSync(file).size; | ||
| total += bytes; | ||
| counted += 1; | ||
|
|
||
| if (!rule.ext.some((e) => file.endsWith(e))) { | ||
| problems.push( | ||
| `${rel}: unexpected file type in ${rule.dir} (only ${rule.ext.join(" or ")} belongs here)`, | ||
| ); | ||
| continue; | ||
| } | ||
| const ceiling = /-scrub(-sm)?\.mp4$/.test(file) ? SCRUB_MAX : rule.max; | ||
| if (bytes > ceiling) { | ||
| problems.push( | ||
| `${rel}: ${bytes.toLocaleString()} B exceeds the ${ceiling.toLocaleString()} B ceiling`, | ||
| ); | ||
| } | ||
| const avifFault = file.endsWith(".avif") && avifProblem(file, bytes); | ||
| if (avifFault) problems.push(`${rel}: ${avifFault}`); | ||
| if (file.endsWith(".mp4") && hasAudioTrack(file)) { | ||
| problems.push( | ||
| `${rel}: carries an audio track. Re-encode with -an — iOS Safari refuses ` + | ||
| `gesture-free autoplay for media that has one, muted or not.`, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (total > TOTAL_MAX) { | ||
| problems.push( | ||
| `walkthrough media totals ${total.toLocaleString()} B, over the ` + | ||
| `${TOTAL_MAX.toLocaleString()} B ceiling. These bytes are permanent in git history.`, | ||
| ); | ||
| } | ||
|
|
||
| const summary = `${counted} file${counted === 1 ? "" : "s"}, ${total.toLocaleString()} B of ${TOTAL_MAX.toLocaleString()}`; | ||
|
|
||
| if (problems.length) { | ||
| console.error(`media budget FAILED — ${summary}`); | ||
| for (const p of problems) console.error(` · ${p}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| console.log(`media budget ok — ${summary}`); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.