Add motion/: one folder per animation asset, discovered like the boards - #13
Add motion/: one folder per animation asset, discovered like the boards#13Jing-yilin wants to merge 24 commits into
Conversation
`npx skills add remotion-dev/skills` installs all twelve into `.claude/skills/`, pinned by `skills-lock.json`. Upstream's files, kept as upstream ships them: `npx skills update` refreshes them, hand-edits do not survive. The repo's own three skills stay in `.agents/skills/`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same layout as clone-prototype, new-ui-mock and prototype-canvas: the files sit in .agents/skills/, .claude/skills/ holds only symlinks. `npx skills update` follows them and leaves the links alone, verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop a folder into motion/src/<bucket>/<slug>/ and it becomes a Remotion
composition named after the folder, appears in the studio, and — once
rendered — plays on the canvas. Same contract the artboards already have:
discovery by convention, no registry to keep in step with the filesystem.
- motion/src/Root.tsx auto-discovers via require.context (rspack's; the
canvas's import.meta.glob is Vite-only and does not exist there).
- meta.json is a JSON sidecar rather than a field in the TSX so both
bundlers can read it: rspack builds the compositions, Vite builds the
canvas and needs the box to size the preview.
- remotion.config.ts points the public dir at ../mockups, which is why
motion/ is a sibling of mockups/ and not inside it: motion consumes
mockups. A composition reaches a board with staticFile("canvases/...").
- canvas/: a Motion page lays the rendered mp4s out at 478pt, the
artboards' own column pitch, so a video lines up with the boards it was
made from. server.fs.allow opens the repo root because ?url assets
outside canvas/ are served, not inlined the way the boards' ?raw is.
- tools/motionkit.py is the motion half of refkit.py: probe, flow, sheet,
compare, selftest. It applies the repo's measurement rule to time —
spatial-gallery looks like a slow camera move and is in fact two
momentum flicks with a dead hold between them.
The measurements taken off a third-party clip are committed; the clip is
not, same split .gitignore already makes for ref-*.html boards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in claude-ios, the clone-prototype lessons, and the canvas's move to Bun. Only README.md overlapped, and only in prose: main's canvas now runs on Bun, so the Motion section no longer calls the two projects npm projects. motion/ stays on npm — Remotion bundles with rspack and ships its own Chrome Headless Shell, so Bun buys it nothing. canvas: bun install --frozen-lockfile, lint, test (1 passed), build all clean against the merged tree. motion: tsc clean, compositions still lists spatial-gallery from folder structure alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0d8b7a528c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| (key: string): MotionAsset; | ||
| }; | ||
| } | ||
| ).context("./", true, /\/index\.tsx$/); |
There was a problem hiding this comment.
Restrict discovery to asset-root entry points
When an asset organizes supporting code under a nested barrel such as src/templates/foo/components/index.tsx, this recursive pattern also treats that file as a MotionAsset. The subsequent render then reads missing meta/Component exports and can prevent the entire Remotion Studio or compositions command from loading. Match only ./templates/<slug>/index.tsx and ./films/<slug>/index.tsx so supporting modules remain safe.
Useful? React with 👍 / 👎.
| const last = Math.ceil(TWO_PI / alpha); | ||
|
|
There was a problem hiding this comment.
Bound card indices for reverse motion
When a reusable gallery receives negative drift or negative flick peaks, thetaEnd is below the initial angle, but these bounds assume travel only increases. That omits a large section of the helix at the start or end of the animation, producing empty edges and popping cards; mixed-direction schedules can similarly exceed both endpoint bounds. Compute the minimum and maximum travel across the composition and derive both index limits from those extrema.
Useful? React with 👍 / 👎.
The Motion page is a page you have to know to open. The welcome board is the one thing anyone opens first, so the renders go there too — under the two rows of example boards, at the same column pitch so the rows line up. Clicking a card opens the Motion page, where the same render plays at full width. A card's cover is the mp4 itself rather than a still: a still of a video is not what the card is offering. `path` on canvas-link now means cover art — a board's path or a rendered mp4's URL — and the card is only as tall as its composition's aspect, because cropping a landscape render into a phone-shaped card throws most of the frame away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cf03d593d5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return ( | ||
| <Composition | ||
| key={key} | ||
| id={slugOf(key)} |
There was a problem hiding this comment.
Namespace composition IDs by bucket
When a template and a film use the same folder slug—a valid arrangement under the documented templates/<slug> and films/<slug> layout—both registrations receive the same composition ID. Remotion requires composition IDs to be unique, so this prevents Studio and render commands from loading the library; the canvas also keys metadata by slug alone in readMotionLibrary(), which can assign either asset the other's dimensions. Include the bucket in both the composition ID and the canvas metadata key.
Useful? React with 👍 / 👎.
| @@ -373,6 +392,216 @@ function layoutWelcomeExtras( | |||
| LIBRARY_LABEL_HEIGHT + | |||
| LIBRARY_GAP; | |||
| } | |||
|
|
|||
| layoutWelcomeMotionRow(editor, page, top); | |||
There was a problem hiding this comment.
Render the motion row when no example boards remain
When the welcome board is the only remaining board but rendered motion assets exist, the earlier if (!targets.length) return exits before this call. This is a supported setup—the root README tells users to delete the supplied board folders—but it silently omits the promised motion cards from the welcome page even though the separate Motion page is created. Move this call ahead of that return or avoid returning when motion assets are available.
Useful? React with 👍 / 👎.
At half a board wide the render was the smallest thing on a board full of phone screenshots, which is backwards: it is the one card with motion in it. 478 is the artboards' own column pitch and what the Motion page already uses, so the card is now the same width in both places. The heading was also wrapping to two lines and setting over the card. The row above it already says to click a card, so this one only has to name itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 21083736eb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const grazing = Math.abs(Math.sin(yaw)); | ||
| const band = 50 - 30 * Math.sin(yaw); // reflection slides with the yaw | ||
|
|
||
| const card = cards[((i % cards.length) + cards.length) % cards.length]; |
There was a problem hiding this comment.
Require at least one card before indexing
When the reusable gallery receives cards={[]}—for example while a caller has no media—the modulo expression produces NaN, so card is undefined and the subsequent card.background access crashes the composition instead of rendering an empty gallery. Guard the empty state or represent cards as a non-empty tuple.
Useful? React with 👍 / 👎.
| /** Every preview is this wide, so the column pitch matches the boards' 478pt artboards. */ | ||
| export const MOTION_PREVIEW_WIDTH = 478; | ||
|
|
||
| const VIDEO_PATTERN = /motion\/src\/([^/]+)\/([^/]+)\/out\/[^/]+\.mp4$/; |
There was a problem hiding this comment.
Discover only the canonical render per asset
When an asset's out/ directory contains more than one MP4, such as an old render alongside a new candidate, this wildcard creates multiple library entries for the same bucket and slug. layoutMotionPage() then assigns those entries the same motion-file:<bucket>/<slug> shape ID, while the welcome row also reuses one ID, so the batch can overwrite or omit renders instead of producing one deterministic card. Match only the canonical out/<slug>.mp4 file or explicitly select one output.
Useful? React with 👍 / 👎.
A review of the tree found the conventions worked only because there is exactly one asset in it. Every fix here is a rule that fails silently on the second or the fifth. Discovery (motion/src/Root.tsx) - The pattern pinned the filename but not the depth, so an asset's own components/index.tsx would have become a composition with no meta and failed deep inside Remotion without naming the folder. - A missing meta.json now throws with the path and the one-line fix. - Two folders of the same name in different buckets now throw: a composition id is its folder name and Remotion's ids are global. - Buckets group the studio sidebar via <Folder>. The join (canvas/src/motionLibrary.ts) - Renders were keyed by slug alone, so films/x and templates/x would have shared whichever meta.json the glob enumerated last and one would draw at the other's aspect. Keyed by bucket and slug now. - out/*.mp4 matched any filename, so a scratch compare.mp4 became a second entry colliding on the shape id. Only out/<slug>.mp4 counts. - Buckets are named in the glob rather than *, so a folder under a third bucket is loudly absent instead of bundled and silently dropped. - meta.json takes an optional name, because humanizing a slug cannot express casing: "luma-ios-launch" is not "Luma iOS launch". - The join is a pure function over paths, with a test for all four rules. Layout (canvas/src/App.tsx, MotionFileShapeUtil.tsx) - Both rows wrap at four across. The welcome strip stops at four and its heading says how many more are on the Motion page; the Motion page runs each bucket down in lines of four, each as tall as its own tallest video. - A culled shape no longer mounts its <video>: tldraw culls with display:none rather than unmounting, and a hidden video keeps decoding. Rendering (motion/render.sh) - render.sh <slug> derives out/<slug>.mp4 from the slug, because a render written anywhere else is a render the canvas does not show. No argument renders every asset. - CRF 28, not Remotion's default 18: out/ holds a preview that loops four-across on a board, and the master is the composition. The committed spatial-gallery render goes 2.2 MB -> 603 kB. Evidence - reference/motion.txt regenerated with tools/motionkit.py, in source pixels — the old series was half-res with a "multiply by 2" footgun in the header. Every number BRIEF.md quotes was re-checked against it: peak 83.5 px/frame at f9, total 1630x582, axis 19.6 degrees. - BRIEF.md pointed at ~/Downloads for the source and listed frames that were never committed. It now names the clip in reference/ and gives the motionkit commands that regenerate the rest. - .gitignore's reference/ rule is an allowlist rather than a list of video extensions, so the next clip dropped in as .webm is ignored too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 178aceaca3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const bySlug = () => | ||
| editor.getPages().find((c) => c.meta.canvasSlug === MOTION_PAGE_SLUG); |
There was a problem hiding this comment.
Reserve the generated Motion page slug
When a board is added at mockups/canvases/motion/—a slug currently accepted by board discovery and not documented as reserved—this lookup reuses that board's page instead of creating a separate Motion page. The later rename and video layout then mix generated motion previews into the board and replace its configured page name; either reject/reserve this board slug or use a namespace that cannot collide with board folders.
Useful? React with 👍 / 👎.
Brings in duolingo-ios, tools/artgen.py, refkit's key and sample --only, and main's rewritten README opening. One conflict, in README.md: main replaced the repo tree block with a "how you use it" paragraph, and this branch had edited that block to mention Remotion's vendored skills. Took main's paragraph whole — the Remotion facts already live in the Motion section below it, which merged clean. Adjusted "Three skills" to say three of ours plus Remotion's twelve, since that count is only wrong once this branch lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 14d7f98875
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if [ $# -gt 0 ]; then | ||
| render "$1" | ||
| else | ||
| for dir in src/*/*/; do render "$(basename "$dir")"; done |
There was a problem hiding this comment.
Restrict bulk rendering to composition buckets
When shared code uses a nested directory under the documented src/lib/ location, such as src/lib/components/, this wildcard treats that directory as an asset even though Root.tsx registers compositions only from films and templates. The loop consequently invokes remotion render components ...; because no such composition exists and the script uses set -e, the bulk render stops before rendering all real assets. Restrict this loop, and the lookup in render(), to the two registered buckets.
Useful? React with 👍 / 👎.
| cd motion | ||
| npx remotion studio # scrub everything |
There was a problem hiding this comment.
Add dependency installation to the motion setup
In a fresh clone following the repository setup instructions, only canvas/ dependencies are installed, yet this new motion guide proceeds directly to npx remotion without ever installing the dependencies in motion/package-lock.json. That leaves the local CLI, React, Remotion, and TypeScript unavailable, so the documented studio, render, and lint commands do not work reliably from a clean checkout. Add an npm ci step before these commands.
Useful? React with 👍 / 👎.
A 68-second brand film taken apart into the fifteen things it actually does, each its own composition: mesh-gradient; count-up, word-cascade, text-marker, word-swap, word-grid; orb-bloom, bokeh-orbit, particle-form, depth-flythrough; card-stack, focus-pull, lens-reveal; pill-expand, logo-outro. They are built to be cut together, which is what shapes the interface: every one takes `durationInFrames` as a prop rather than reading it from `useVideoConfig` — inside a `<Sequence>` that still reports the composition's length, so a template that trusts it stretches wrong the moment it is placed in a cut — and every one is fully settled well before its own last frame, so two can butt against each other with nothing to hide the seam. src/lib/ carries what more than one of them needs: the palette with the frame each colour came off, the two faces, the timing helpers, the mesh ground, the orb. It sits outside Root.tsx's require.context on purpose, so a shared module never becomes an empty entry in the studio sidebar. Three things worth writing down, all now in comments where they bit: - `radial-gradient(circle 55% at ...)` is invalid — a circle's radius may not be a percentage — and one invalid layer silently drops the WHOLE background shorthand. The mesh painted no blobs at all until this was `ellipse R% R%`. - A bare width/height does not survive being a flex item next to something wide. The orb came out an egg; `flex: none` lives in Orb.tsx, not at the call site. - Unpinned, `loadFont()` fires 126 requests for Inter at the head of every render. fonts.ts pins weights and subset. The film's motion is reproduced; its wordmark and its ad copy are not, and the clip stays out of the repo. Templates carry this repo's own strings — which is also what makes them templates. src/lib/README.md has the whole provenance and the commands to reproduce any number in it; each template's README has its own measurements and its deviations. motionkit grows `swatch` (colours off one frame, as a grid or a crop census) and `sheet --from/--to` (a contact sheet of one shot rather than the whole clip). Both are how the numbers above were read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ef72f0d38a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if lo >= hi: | ||
| sys.exit(f"motionkit: --from {a.start} is not before --to {hi}") | ||
| indices = [round(lo + i * (hi - lo) / max(1, a.count - 1)) for i in range(a.count)] | ||
| frames, _ = read_gray_rgb(a.video, a.width) |
There was a problem hiding this comment.
Decode only the frames selected for the contact sheet
When sheet is run on a long reference clip—or even with a narrow --from/--to range—read_gray_rgb() still decodes every frame into memory before the selected indices are used. At the default 320px width, the documented 68-second, 30fps example requires roughly 336 MiB just for RGB pixels and scales linearly with clip length, so this can become extremely slow or be killed for memory despite requesting only ten tiles. Seek or select the requested frames during ffmpeg decoding instead.
Useful? React with 👍 / 👎.
| rather than inside `ellipse()`, which takes no angle. The radii are | ||
| allowed past 100% — that is how the aperture runs off the edges at the | ||
| end of the shot without anything having to be scaled up to let it. */} | ||
| <AbsoluteFill style={{ transform: `rotate(${tilt}deg)` }}> |
There was a problem hiding this comment.
Keep the rotated reveal covering the frame corners
With any nonzero tilt—including the default -18—rotating a viewport-sized AbsoluteFill makes its rectangular corners move inside the parent, leaving triangular areas where the original background remains visible. Enlarging the ellipse cannot fill those areas because the revealed background is itself confined to that rotated rectangle, so the default animation never actually reaches its promised full-frame reveal. Oversize the rotating layer or rotate only the aperture coordinate system while keeping the revealed content full-frame.
Useful? React with 👍 / 👎.
| style={{ | ||
| fontSize: size * s.k, | ||
| lineHeight: 1.35, | ||
| padding: `${0.5 * s.k}em ${0.9 * s.k}em`, |
There was a problem hiding this comment.
Avoid applying the perspective scale twice to padding
Whenever an orb is away from the focal distance, fontSize: size * s.k already scales every em measurement by s.k; multiplying the padding's numeric em value by s.k again therefore makes bubble padding grow as k², while the orb, text, gap, and radius grow linearly. Near the camera the default parameters can produce several em of padding and an oversized bubble that overwhelms the frame, breaking the intended perspective projection. Keep the padding values constant in em, or express linearly scaled padding in pixels.
Useful? React with 👍 / 👎.
Rendered all fifteen, tiled them against the source, and went back to the clip for everything that looked off instead of tuning by eye. Two of them turned out not to be replicating anything the film does. word-swap was a crossfade. Sampled f213-f228 one frame per tile and the film does something else entirely: a gap opens between two words over six frames, the word changes on ONE frame - no blur, no rise, no dissolve - and a sphere fades up into the space that was just made for it. The hard cut is the whole effect. Rewritten around it; the template that blurs words past each other is word-cascade, and this one is deliberately not that. depth-flythrough scattered its orbs. Each got a random x and y inside a wander box, which is the obvious way to build "a crowd of spheres" and piled them into a blob with no depth in it. In f1480-f1545 the orbs sit on ONE straight line passing left of the camera, converging on x 0.56 / y 0.50, each 0.63 the size of the one in front. wander and its seed are gone; vanish and offset replace them and screen x now runs through the same perspective divide as the size. Bubbles alternate speaker off the label index, not the orb index - every other orb is bare, so i % 2 put every bubble on the same side and the alternation never fired - and are culled below 20px of type, because past that a third bubble is litter rather than distance. Four more numbers that were guesses: - orb-bloom to: 1.7 -> 2.05. The 16:9 diagonal is 2.04 frame heights, so anything smaller leaves the corners showing. Chips now leave over the last ten frames, which is where the film cuts, so the template has an end state and not just a hold. - bokeh-orbit count: 22 -> 48. The sparseness was the count, not the radius; the reference ring is a dense overlapping chain running off three edges. - particle-form rim: 0.62 -> 0.35. 0.62 rendered a hollow ring. - depth-flythrough bubble padding was px * k inside an em box, so it scaled twice and the near bubble came out all padding. Eleven README reference ranges were wrong by 50-400 frames. Corrected against the clip, and each README now carries the motionkit command that reproduces its sheet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
super-prototyping/canvas/src/App.tsx
Line 642 in 0e312b0
When a checkout has rendered motion assets but no HTML boards, this return prevents the later layoutMotionPage() call from running, so the canvas exposes none of the renders despite motion discovery being independent of the board library. Continue with an empty library so a motion-only project still gets its generated Motion page.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| id: `canvas-row-heading:${page.id}:${title}`, | ||
| text: title, |
There was a problem hiding this comment.
Keep the welcome motion heading ID stable
When the rendered asset count crosses four or otherwise changes its overflow count in a persisted canvas, title changes and therefore produces a new shape ID; the old heading is never updated or removed, so reloads accumulate overlapping headings at the same coordinates. Use a stable row ID and let createAnnotation() refresh its text.
Useful? React with 👍 / 👎.
| speed = [np.hypot(dx, dy) for _, dx, dy, _, _ in rows] | ||
| peak = int(np.argmax(speed)) |
There was a problem hiding this comment.
Handle flow inputs with fewer than two frames
When flow receives a valid one-frame clip, measure() returns no displacement rows and np.argmax(speed) raises on the empty sequence (with rows[-1] failing next), so the command exits with an internal traceback rather than reporting zero motion or a useful input error. Guard the empty-row case before computing the peak.
Useful? React with 👍 / 👎.
`src/films/brand-film/` is the Delphi set assembled back into one 43.2s piece, in the source film's own shot order. It is a shot list and nothing else: no animation, no easing, no colour, no copy, and exactly one prop overridden per shot — `durationInFrames`. That single override is the composability claim the whole set was built to make, and this is the first place it is actually exercised, because inside a <Sequence> the config's own duration is the film's 1296 frames and not the shot's 84. Then the point of assembling it. Comparing the cut against the source shot by shot showed twelve of the fifteen templates had been authored between 1.3x and 2.4x too small, and every one of them looked right on its own: a template scrubbed alone has no scale, since it fills its own frame whatever size its contents are. Corrected against a measured frame in each case, and the prop tables follow. Beyond the numbers, three things the measurement decided rather than taste: `orb-bloom`'s chips are upright (the italic belongs to `word-grid`) and its bullets are flat pale discs, not lit orbs; `card-stack`'s cards are one key light washing across the row, not eight differently coloured cards. `motionkit extent` is that measurement, committed so the numbers reproduce. It subtracts a wide Gaussian from a frame and boxes what survives, so type and hard chrome register while gradients and bokeh do not. Measured at the same --width, a 2880-wide reference and a 1920-wide render give directly comparable fractions. `compare` cannot answer this — it wants a frame aligned pair, and 43.2s against 68.4s in a different order is not one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5ba8556caf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| frames = np.frombuffer(raw, np.uint8).reshape(-1, dh, dw) | ||
| return frames.astype(np.float32), 1.0 / scale |
There was a problem hiding this comment.
Stream frames while measuring optical flow
When flow is run on the documented 68-second, 30-fps reference at the default 540px width, this retains the complete ffmpeg byte buffer and then allocates a float32 copy of every frame—approximately 1.6 GiB before FFT working memory. This can make the primary measurement command swap heavily or be killed on modest machines; process consecutive frames incrementally instead of materializing the entire clip.
Useful? React with 👍 / 👎.
| print(json.dumps( | ||
| {"fps": round(fps), "width": w, "height": h, "durationInFrames": n}, | ||
| indent=2, |
There was a problem hiding this comment.
Preserve fractional source frame rates
For common sources such as 30000/1001 or 24000/1001, this rounds 29.97/23.976 to 30/24 in the generated meta.json snippet even though the preceding probe output uses the exact rate. Copying the advertised snippet into a composition therefore changes its playback rate and duration, with drift increasing for longer clips; emit the measured fractional value instead of rounding it.
Useful? React with 👍 / 👎.
| sx = sum(dx for dx, _ in moving) | ||
| sy = sum(dy for _, dy in moving) | ||
| if sx: | ||
| print(f"axis {np.degrees(np.arctan2(sy, sx)):.1f} deg from horizontal") |
There was a problem hiding this comment.
Report the axis for vertical motion
For a purely vertical pan, every measured dx and therefore sx is zero, so this condition suppresses the axis line even though atan2(sy, 0) correctly yields ±90 degrees. Since identifying the pan axis is an advertised output of flow, print the angle whenever moving samples exist rather than requiring horizontal displacement.
Useful? React with 👍 / 👎.
… wrong Comparing the assembled film to the reference again turned up three faults in the film and three in the comparison. Both kinds are written down, because half the corrections in the last pass were to things that were never broken. In the film: - `pill-expand`'s card grew in six frames instead of twenty-five, and its title and body crossfaded together. Measuring the card's own pixel width gives 529 -> 821 over +23 to +48, ease-in-out cubic to within a percent at every sample; measuring ink under luma 160 in each half of the card gives the title leading the body by two frames, 35% up against 5% at +42. The card is briefly empty in the reference too, at +38 to +40 -- but it is still growing there, which is why it never reads as a blank slab. - `DIM` spreads `MESH`, so when the band stopped being a triangle its `bandLeft` and `bandRight` changed meaning underneath it -- half-height widths became ground-crossings -- and its band silently doubled. That put 150 luma across the left half of `particle-form` and `focus-pull` where the reference has 105. Re-fitted by binning every pixel along the band's own axis and masking the ink, which is the only way to read a shot that never has an empty frame: rmse 1.2 luma, against the 30-50 it was out by. - "f1400 is the bare gradient" is not true and two files said so; it carries the block's bloom and a glyph of the next shot. In the comparison: - `depth-flythrough` is the one shot that runs the reference's own length on the reference's own clock, and it was paired as if it did not. It read 0.70, then 1.25, and it is 1.01. - `word-swap`'s extent band was reading type in one clip and orb in the other. Measured directly, the reference's orb settles at 0.142 W and ours at 0.1375. - `bokeh-orbit`'s 0.89 and `pill-expand`'s 0.92 are `extent` being blunt, not the renders. The film README now says where it is blunt and why five of the fifteen shots cannot be measured with it at all. Every other row of the table is inside 1.08. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f18c1f05a5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (props.src !== shape.props.src || props.h !== shape.props.h) { | ||
| editor.updateShape({ | ||
| id: shape.id, | ||
| type: shape.type, | ||
| props: { src: shape.props.src, h: shape.props.h }, | ||
| }); |
There was a problem hiding this comment.
Reflow rows when preview height changes
When an asset's meta.json aspect changes on an already persisted canvas, this block resizes only the preview. Its existing label is not repositioned because createAnnotation() only updates text, and previews in subsequent wrapped lines or buckets retain coordinates calculated from the old height. A taller render therefore overlaps those labels or later previews after an ordinary reload; update the generated positions together or trigger a full relayout when the height changes.
Useful? React with 👍 / 👎.
…to time a fade
Two passes have now split `pill-expand`'s title and body, in opposite
directions, and both were reading the same broken instrument: a count of
pixels under luma 160 inside the card. A count crosses a threshold, it does
not ramp. Text at 30% opacity on white sits at luma 187 and registers as
nothing at all, so the count reports a fade as a step and the step lands
wherever the two halves happen to cross 160 -- which for a heavier title and a
lighter body is not the same frame.
Measuring the mean luma of each half instead, normalised between the empty
card at +40 and the settled card at +60, is linear in opacity because the card
is white and the ink darkens it in proportion:
+42 +44 +46 +48 +50 +52
title 0.18 0.46 0.73 0.93 0.97 0.99
body 0.11 0.37 0.68 0.90 0.95 0.97
They land together -- the body trails by about half a frame, finer than a
30 fps template can express -- and the shape is an ease-out quad over 11
frames, not the helper's default cubic, which is a frame ahead the whole way.
Both halves now run off one `enter`; worst gap against the reference 0.09 of
the fade, against 0.41 before.
`extent`'s pill-expand row goes the way `word-swap`'s did. It read 0.92, and
0.89 once its pair was corrected -- this template's offsets run from `at`, and
the cut opens it on eight frames of bare ground, so film frame 481 is f1080
and not f1088. Neither number is about the card: both are the paragraph behind
it, plus 0.02 W a side of blur residual from subtracting a 24 px Gaussian off
an already-blurred backdrop. The card itself is 409 px of 960 in the reference
against ours at 410, tracking within 3 px at every sample of its open, so the
twenty-five-frame ease-in-out cubic restored last commit is landing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dc5cbfc19e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const missing = cards.filter((card) => !editor.getShape(card.id)); | ||
| if (missing.length) editor.createShapes(missing); |
There was a problem hiding this comment.
Reconcile changes to the welcome-card subset
When a newly rendered film or alphabetically earlier asset enters the first four returned by readMotionLibrary(), assets changes but this only creates the new card: existing cards are never moved to their new indices, and the card displaced from the four-item slice is never removed. On a persisted canvas, the new card therefore overlaps the previous first card while the evicted card remains as a fifth preview, defeating the bounded row until the user manually forces a full relayout. Reconcile generated card membership and positions whenever the selected subset changes.
Useful? React with 👍 / 👎.
…rd-stack The film opened on 72 frames of bare `mesh-gradient`. Two things wrong with that. The reference opens on black and rises into a ground with type already on it -- it never holds on a bare ground anywhere in 68.4 s -- so ours put its brightest frame where the source has its darkest. And it was the wrong ground: `mesh-gradient` is fitted on f1172-f1280 by its own README, the crimson second half, while the opening's ground is `word-cascade`'s cocoa radial. That template's first line claimed it was "the warm ground the reference film puts under its opening shots", which is how it got there; the line is now corrected, and says what it once cost. The shot is dropped and a 24-frame fade from black replaces it, in the film rather than in a template, because a template that faded up on its own could only ever be the first shot of a cut. Mean luma over the settled ground at f24 runs 0.00 / 0.10 / 0.41 / 0.85 / 1.00 at f0/6/12/18/24; an ease-in-out cubic gives 0.00 / 0.06 / 0.50 / 0.94 / 1.00. The cut is 1224 frames, 40.8 s, and every frame number in the film README and the ratio table moves with it. `card-stack` was the worst shot on the contact sheet, and two of its props were out: - `blur` 7 -> 34. On a 38-row band at 0.72-0.79 of H, counting pixels that step more than 4 luma finds edges: the reference has 11, which is the sharp card's two and little else. 7 rendered 35 -- every card in the row hard edged. 20, 26, 30 and 34 render 35, 22, 15, 10. - `riseFrames` 24 -> 20 and `step` 1.5 -> 1.0. Timed on the median of the row's whole top edge, normalised into a fraction travelled so the two clips' sizes drop out: the reference is 0.68 / 0.85 / 0.94 / 0.98 / 1.00 at +6 to +19, ours was 0.59 / 0.74 / 0.88 / 0.91 / 0.96 and five frames late. It now renders 0.67 / 0.85 / 0.93 / 0.99 / 1.00. The keystone did not fit, and the README now says so at length rather than carrying a knob that pretends otherwise. Six props were swept at it. Every one traded coverage for lean, and the reason turned out to be that all six were measuring the same artefact: the first four grids compared the reference's settled f57 against our frame 19, which is still mid-slide, and the two `perspectiveOrigin` grids were reading a row that the camera move had sunk 0.14 of H down the frame. Putting the row back with `top` looked like a landing -- area 0.566 against 0.522, top edge 0.480 against 0.469, slope 0.374 against 0.367 -- and rendered a sharp card towering over its neighbours, because `top` moves every card in the layout plane while the projection only drags the ones that have receded, and the sharp card is at z=0 where `perspectiveOrigin` cannot reach it. Two things were wrong underneath all of it. The slope metric counted the soft transitions between neighbouring cards as lean, which is why the reference read 0.367 and nothing could reach it. And the reference's card tops at f61 are 0.444 to 0.556 of H scattered about its sharp card's 0.451 -- there is no systematic fan in that row for a camera height to match. What is left is per card and wants the whole row pushed off z=0, which is a different change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7d373793bd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const missing = shapes.filter((shape) => !editor.getShape(shape.id)); | ||
| if (missing.length) editor.createShapes(missing); |
There was a problem hiding this comment.
Reposition previews when discovery order changes
When a newly rendered asset sorts before an existing asset in its bucket, placed assigns the existing preview a new position, but this block creates only the missing shape and the update loop below never updates x or y. On a persisted Motion page, the new preview is therefore created on top of the old first preview until the user forces a full relayout, so ordinary asset discovery produces an overlapping layout.
Useful? React with 👍 / 👎.
| s = json.loads(out)["streams"][0] | ||
| num, den = s["r_frame_rate"].split("/") | ||
| fps = float(num) / float(den) | ||
| frames = int(s.get("nb_frames") or 0) |
There was a problem hiding this comment.
Treat nonnumeric frame counts as unavailable
When ffprobe reports an unavailable frame count as the literal "N/A", as occurs for common WebM/Matroska streams, int("N/A") raises before the packet-count fallback can run. Since every motionkit command calls probe(), such a reference cannot be probed, measured, or sampled; parse only numeric values and otherwise fall back to -count_packets.
Useful? React with 👍 / 👎.
…teleporting The film butted every shot against the next, on the claim in its own header that a template which settles before its last frame needs nothing at the seam. Measured, that was the film's biggest fault. ffmpeg's scene detector at 0.2 finds one hard cut in the source (f1172, 0.87) and two soft events; it found ten in ours, four at 1.00. Mean |dL| from one frame to the next across the seams, ranked: bokeh-orbit -> text-marker 209, depth-flythrough -> lens-reveal 165, focus-pull -> depth-flythrough 138, pill-expand -> count-up 106, lens-reveal -> word-grid 81, count-up -> orb-bloom 69, orb-bloom -> particle-form 38, card-stack -> word-swap 30. The other five were under 9. The source's joins are cross-dissolves. Mean luma across a cross-fade of two stills is linear in the fade's opacity, so the normalised luma across a join IS its curve, and every (length, easing, start) was scored against it. Four short ones -- f667, f822, f1467, f1586 -- fit ease-in-out cubic best, at 8, 11, 10 and 9 frames, rmse 0.014-0.036, which is the curve the fade from black already used. So a shot in the cut now carries a third number, how many of its first frames overlap the previous shot's last, and fades in over them on that curve; nothing else in the film changed and no template knows. Seven joins dissolve, at the source's own lengths where the source joins the same two shots (depth-flythrough in at f1467 and out at f1586, orb-bloom out at f1336, count-up out at f1274, card-stack out at f140, lens-reveal out at f1776) and at the nearest join of the same kind where it cuts to footage between them. pill-expand -> count-up stays a cut: it is the source's one hard cut, 238 -> 136 there against 240 -> 135 here. After: 52, 49, 34, 106, 15, 24, 11, 14. The source's own dissolves peak at 44 and 49 per frame. The scene detector finds five events, and two of the four soft ones are lens-reveal's own reveal, which steps 66 per frame where the source's steps 15 -- the template's, not the cut's, and written down rather than changed. The cut is 1147 frames, 38.2 s; the frame numbers in the README move with it. Two of the fits did not take the one curve and the README says which: the card-stack fade prefers ease-in-out quad over 14 (0.021 against the cubic's 0.048), the join into the crimson before word-grid an ease-out over 22 (0.035 against 0.110). And count-up -> orb-bloom fades where the source defocuses the numeral; the faithful fix is a leave on count-up, which was not made. word-cascade, separately, moved its type in one-frame jumps: 163 px sideways at f10 as "got" joined the row and 150 px up at f14 as the second line appeared. The template laid out only the units that had arrived, so each arrival re-centred the flex row, and it did that on the strength of a README line saying the reference re-centres "You've" alone at f20 and the pair at f24. Measured (ink over luma 140 at 960 wide), the reference does the opposite: "You've" holds its left edge at 0.263 of W from f17 to f31 while "got" arrives beside it, and what moves is the whole block, once, upward -- top 0.400 -> 0.294 of H over f18-f31, 0.26 / 0.41 / 0.49 / 0.58 / 0.68 / 0.75 / 0.82 / 0.88 / 0.91 / 0.92 / 0.96 / 0.98 / 1.00 of the way, ease-out cubic over 16 to rmse 0.033. Every unit is now laid out from frame 0 and the block starts centred on its first line and lifts (`liftAt` 10, `liftFrames` 16). Rendered, the left edge holds 0.263 from f11 and the block top moves in steps of 0.026, 0.023, 0.018 ... 0.002 of H; the reference's largest step is 0.028. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e1c140a6ea
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| rise, | ||
| ), | ||
| display: "inline-block", | ||
| color: accent && piece.includes(accent) ? accentColor : color, |
There was a problem hiding this comment.
Apply accents across animation-unit boundaries
When accent spans multiple animation units—such as accent="digital mind" with unit="word", or any multi-letter accent with unit="letter"—no piece individually contains the full substring, so the requested accent is never rendered. Conversely, a substring smaller than a piece recolors the entire piece. Track each piece's range within the full text and color the actual matching range rather than testing each piece independently.
Useful? React with 👍 / 👎.
The reference carries "Your / digital mind / is born" over the particle figure: f1380 has 3.77% ink left of 0.45 W and 5.78% right of it, at once. The cut ran them as sequential shots, so they never shared a frame -- the right of the frame was empty for 100 frames and then the left was for 72. `focus-pull` now overlaps its whole length as a layer rather than a dissolve: it composites at full opacity, and the cut passes it `gradient: null` so the shot on top does not repaint the `DIM` ground both templates draw. `GradientProps | null` is the only behavioural change to the template; alone it keeps its ground. Placing it needed the arrival measured, and a plain box mean cannot do it: the type covers well under half its box and the ground under it drifts by more than the type contributes. Mean excess over the box's own per-frame median can -- the median is the ground, and excess light is linear in opacity where an ink count over a threshold crosses a step. It says the figure climbs from f1342 and the type from f1345: four frames apart, not the twenty the first placement assumed. That twenty came from this template's own header, which recorded f1372 -- the frame the *push* was measured from -- as the start of the shot. So `particle-form` is 80 frames with `focus-pull` overlapping 72, which puts the type at frame 8 of the figure's run, four frames after its dots start at frame 4. Both run out together, and the shot's last six frames are the figure alone, as in the source. Laid over a ground that no longer moves, the block's full-strength opening read as a pop -- 0.00 to 1.00 of final in one frame, the same fault the opening shot was just fixed for. The source ramps 0.01 / 0.06 / 0.15 / 0.25 / 0.34 / 0.43 / 0.53 / 0.62 / 0.76 / 0.87 / 1.00 at f1345 / 47 / 49 / 51 / 53 / 55 / 57 / 60 / 65 / 68 / 72, a straight line at 0.045 a frame: `fadeIn`, 22 frames, linear. The block's own area is flat over f1358-f1367 while the light still climbs, so it is opacity and not growth. The film is 1055 frames. The scene detector at 0.3 finds one event in it, the hard cut at 15.6 s, which is the one the source has. Still off, and written down rather than papered over: our figure holds flat once its fade lands where the source's keeps growing, 3.51% to 4.93% of the left half over f1376-f1389. That is `particle-form`'s documented hold and this pass did not touch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
Deploying super-prototyping with
|
| Latest commit: |
d300f34
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a932e028.super-prototyping.pages.dev |
| Branch Preview URL: | https://worktree-remotion-skill.super-prototyping.pages.dev |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dcb077fd50
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (props.src !== shape.props.src || props.h !== shape.props.h) { | ||
| editor.updateShape({ | ||
| id: shape.id, | ||
| type: shape.type, | ||
| props: { src: shape.props.src, h: shape.props.h }, | ||
| }); |
There was a problem hiding this comment.
Refresh persisted motion shape names
When an asset's optional meta.name changes without changing the MP4 or its aspect ratio, this update is skipped and the persisted shape retains its old props.name. Although the separate caption refreshes, MotionFile still uses the stale value for the video's title and getText(), leaving canvas search and accessibility metadata inconsistent after reload; compare and update name alongside src and h.
Useful? React with 👍 / 👎.
A second film, and the first one that is about the repo rather than about a reference: seven shots at 300 frames, walking `mockups/canvases/duolingo-ios` through the phases the clone actually ran — grid the capture, sample it region by region, try to name the face and refuse, generate eight boards off one token block, re-render and diff, park the reference under the replica. The phone is not a screenshot. Every board on screen is the artboard's own HTML in an <IFrame> out of `mockups/`, so the reel cannot claim a fidelity the boards have since lost: edit a token and the next render shows the edit. And every number it says out loud lives in `data.ts`, copied off that folder's two evidence boards and its README — the three probes with the technique that took each, the 0.353 that made `refkit font` refuse, the eight deltas and their mean. Nothing rounded to fit a line. The shots dip rather than dissolve. `brand-film` cross-fades because its source does; two shots of small type overlapping would put two sets of numbers on one frame at the moment the reel is asking to be read. Same reason every shot's last element settles at least six frames before its own fade begins. `index.tsx` throws at import if the cut does not sum to `meta.durationInFrames`, so the shot table in the README cannot drift from the film without the render failing first. `src/env.d.ts` is what lets the comparison figure come in by `import` — `setPublicDir` points staticFile at the artboards, which leaves `assets/` unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
The reel is about reading a repository — a token block, a diff, a folder of evidence — so it now sits on the ground that material is normally read on. GitHub's light theme by its own token names: `canvas.default` for the ground, `fg.default` and `fg.muted` for ink, one `accent.fg` blue for everything the film points with, one `danger.fg` red for the single refusal. The dark theme was the logo's, and three things in it only worked on black. The boards were white-edged panels lit from behind; they are Primer cards now, one hairline border and a soft resting shadow, because on white there is nothing to light and a glow only fogs the board's own edge. The end card's wordmark had a lilac bloom behind it, which is gone. And the comparison figure has a white ground of its own, which on a white film left it with no edge at all — Primer's border is that edge. Two colours deliberately did not move, and the README says why. `MINOR` and `MAJOR` stay cyan and red because `refkit grid` draws cyan every 10pt and red every 50; they are the tool's colours, not the theme's, and they are only ever drawn over a board. `DUO_GREEN` is `--d-u-green`, measured off the capture. The film's accent blue took over every job `MINOR` was doing as chrome, which it should not have been doing in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 87f3acc66a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const missing = shapes.filter((shape) => !editor.getShape(shape.id)); | ||
| if (missing.length) editor.createShapes(missing); |
There was a problem hiding this comment.
Remove previews whose renders are no longer discovered
When a canonical MP4 is deleted or an asset is renamed, an existing persisted Motion page keeps its old motion-file shape and caption because this path only creates missing current shapes and never removes generated IDs absent from assets. On the next load the stale preview remains, usually pointing at a Vite URL that now returns 404; reconcile the generated shape set against the discovered assets during ordinary initialization rather than requiring a manual force-relayout.
Useful? React with 👍 / 👎.
The reel dipped: every shot faded to nothing inside its own slot and the next arrived on empty ground. The argument for that was that overlapping two shots of small type would put two sets of numbers on one frame where the reel is asking to be read. The argument was right about the type and wrong about the join — what it actually produced was eight frames of blank white page between every pair of shots, which is more abrupt than a dissolve and not less. The slots in the cut are unchanged. Each shot's <Sequence> now runs OVERLAP frames past its own and spends them getting out of the way, and the type problem is handled where it lives, in `useJoin`. The two are kept apart in time rather than dissolved evenly: the fall eases out — deliberately not `leave()`, which eases in and so holds near full opacity until the last moment — and is under a tenth six frames past the cut, and the rise waits those six frames out. They cross at roughly 8% against 40%. Every shot writes its heading at the same 96, 92, so that separation is the whole trick. The rise is also what stopped three shots punching a hole through the one they replace. Sample's board, Face's headline and Generate's token block never had an entrance of their own, which under a dip cost nothing because there was nothing behind them; under an overlap they came up at full opacity on top of a shot still at 60%. They now arrive with the shot. Two smaller things follow. The phase stamp, the only thing on screen that changes its text without moving, now fades across the join on its own beat instead of flicking over on the cut frame. And the end card takes six frames of head like every other shot, so the wordmark is not rising through the tail of the figure before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: df68f7c76c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| @@ -472,6 +724,7 @@ function initializeCanvasLibrary(editor: Editor) { | |||
| } | |||
| } | |||
|
|
|||
| layoutMotionPage(editor, libraryPages); | |||
There was a problem hiding this comment.
Initialize motion pages without canvas boards
When rendered motion assets exist but readCanvasLibrary() returns no HTML boards, the earlier if (!library.length) return exits before this newly added call. The Motion page is therefore never created and ?canvas=motion cannot display otherwise valid renders, so initialize the motion library before the board-only early return.
Useful? React with 👍 / 👎.
The reel was written for someone who already knows what a corner fit is. It opened on `1A · GRID`, put `--d-card-x 24.1pt / row scan at y 150` beside two more like it, printed a refusal as `0.353 · NO CALL · the top two sit inside 0.05`, and scrolled twenty-five lines of raw `:root` past eight boards. All of it true, none of it readable in a second and a half by anyone meeting the repo for the first time — and a film that looks like it is proving something without being legible is the failure mode this project exists to avoid. So the numbers stay in `data.ts` and mostly stop at the screen. The phase stamps become `step 1 of 6`; the eyebrows become measure / sample / typeface / build / check. Each shot keeps one or two measurements with a plain gloss: three sizes off the grid without the scans that took them, three probes named by what was read rather than the token they land in, a confidence bar that is never printed as a figure, the `:root` block under the film's own names for its values, one mean delta with the sentence that makes 2.04 a size you can picture rather than nine numbers with no scale. Everything dropped is still one file away, with a comment saying where it went and which evidence board it came off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 20b3fc1853
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| capture_output=True, text=True, check=True, | ||
| ).stdout | ||
| s = json.loads(out)["streams"][0] | ||
| num, den = s["r_frame_rate"].split("/") |
There was a problem hiding this comment.
Use the average rate for variable-rate inputs
When probe receives a variable-frame-rate capture, this reads FFmpeg's base rate rather than its average playback rate; FFmpeg defines r_frame_rate as the rate able to represent every timestamp—“it is the least common multiple of all framerates in the stream”—whereas avg_frame_rate is the average (FFmpeg AVStream documentation). Consequently, both the displayed duration and the generated meta.json FPS can be badly wrong even after preserving fractional values, so use avg_frame_rate or derive the rate from frame count and duration.
Useful? React with 👍 / 👎.
The shots overlapped but nothing crossed a join, so every cut was a dissolve between two unrelated frames — which is what made seven shots read as seven slides. Three of these joins were never two frames. Shots 1 and 2 both hold screen 01. Shot 3's headline is `.card .u` on that screen — "Order food and drink" at 96px instead of 19.4. Shot 4's eight boards are shot 5's eight rows. A fourth is a sentence rather than a picture: shot 3 ends on the stand-in the tool declared, and RECIPE now opens on `typeface ui-rounded`, which is where the board's own `:root` opens too. So each of those moves instead of cross-fading. `useJoin` gains a carry mode: a carried layer skips the fade on the side it is handed across, and skips the shrink in both directions, because the shrink is there to put a *dissolving* shot behind its replacement and a 1.5% wobble is exactly what gives a match-move away. Five of the seven shots now return two AbsoluteFills — the carried object, and everything that is theirs alone. Both sides run the same ramp on their own clocks (`frames - 2` against `frame + 2`), so they agree to the pixel on the frame they share: measured at 0.38 MSE across the strip handover, which is the h.264 noise floor and not a seam. Verify→TwoRows and TwoRows→End stay dissolves. The comparison figure is a bitmap whose top row needs 0.80 horizontally against 1.04 vertically to line up with the strip, and a match-move that has to stretch to land is a worse lie than a cut. Also, since the thumbnails took 23px out of Verify's row: gap 20 -> 16, label 250 -> 240, so `08 league promotion` still clears its bar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
Shot 1 started with the board already parked at 250, 202 beside a heading and a readout, which buries the one thing the film is about under the film's commentary on it. It now arrives centre frame at 0.95, holds six frames with nothing else on screen, and walks left to 250, 202 — the heading rises as it goes, and the grid starts drawing before it has finished settling, so the ruler comes down onto the board rather than after it. That makes three legs on one object with no cut in any of them: centre, `HELD_A`, then `HELD_B` in shot 2. `between()` nests, so a board can be mid-leg on two legs at once. The heading waits until frame 30 because its line runs to about x 1016 at top 92 and the board crosses under it until the walk is half done. Measure 46 -> 66, so the film is 320 frames (10.7 s). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 48c4a969c9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return s["width"], s["height"], fps, frames | ||
|
|
||
|
|
||
| def read_gray(path, width=None): |
There was a problem hiding this comment.
Honor display rotation when probing dimensions
When a phone recording stores portrait orientation in its display matrix instead of the coded dimensions, width and height here describe the unrotated stream even though FFmpeg applies autorotation by default during decoding (FFmpeg advanced video options). probe therefore emits a landscape meta.json for a portrait clip, while read_gray derives its scale and source-pixel conversion from the wrong axes, distorting frames and reported motion. Apply the display rotation to the probed dimensions, or disable autorotation consistently when decoding.
Useful? React with 👍 / 👎.
The reel jumped from "we could not name the typeface" to eight finished boards, which put the two phases that do the actual work off screen. The design system was a scrolling list in the corner of the build shot, and asset extraction was not in the film at all — so a viewer came away thinking the boards were drawn rather than measured and cut. Two new shots, and `Generate` gives up the list it was carrying. `System` (step 4) is the block on its own: the face on a rule, twelve swatches, nine measurements, and the line the whole repo turns on — nothing here was chosen, every value was read off the screenshot. Split by what the value is rather than by index, so adding a colour to RECIPE puts it in the swatches without touching the shot. `Art` (step 5) draws six boxes out of `crops.json` on screen 01, in the screen's own pt, then shows the six PNGs those boxes cut, at 1.55x on a common baseline because their heights are the measurement. 128 pieces, and one sentence for the comparison: cut, they are the original's own pixels; redrawn, the best attempt still moves things. The four scores behind that sentence (0 · 3.96 · 18.41 · 38.53) are in `data.ts` above CROPS, with the size effect that keeps icons in CSS. `Generate` (step 6) is now only what it claims: one script, two inputs, eight screens. The strip re-centres to x 359 now that nothing sits to its left. Screen 01 is on screen continuously from frame 0 to frame 282 — five placements, six shots, no cut in any of them. It comes back at 0.48 beside the block that describes it, grows to 0.78 to be cut up, and flies into the first slot of the strip. Six of the eight joins are now carried rather than dissolved. The crop boxes leave on their own ramp (40, over 10) rather than the shot's, because that layer does not fade — `Generate` flies the same board on — so a box still 90% up on the last frame popped off on the cut: 86.05 MSE across the handover, against 2.52 once it is gone. The 2.52 that is left is the iframe rasterising a second time — taking the spent overlay out of the tree entirely does not move it — and at 44 dB it is not a seam. `crops.json`'s six boxes and the 128 count reach `data.ts`, and `assets/art/` turns out to be committed, so the PNGs load through `staticFile` the same way an artboard does. Nothing new is committed for the film. 420 frames (14.0 s), eight numbered steps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cfa0a1f50c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "name": "motion", | ||
| "version": "1.0.0", | ||
| "private": true, | ||
| "license": "MIT", |
There was a problem hiding this comment.
Match the package license to the repository
This package advertises the newly added motion code as MIT, but the root LICENSE and README.md state that the repository's own code is Apache-2.0. Even though the package is private, SBOM and license-scanning tools consume this field and will misclassify the code's licensing; use the repository's Apache-2.0 identifier unless this subpackage is intentionally relicensed and accompanied by the appropriate license terms.
Useful? React with 👍 / 👎.
The shot was spending its second half on a progress bar under "how sure the match is", drawing FONT_SCORE as a length. Two problems with it. A viewer who has not been told what a glyph-shape score is reads a short bar as a loading indicator, not as a refusal — and a score is not what this step produces. The step produces a size. So the bar and the TOO CLOSE TO CALL row go, and what replaces them is how a size is actually read: the height of a capital divided by how tall capitals are in the face you set it in. Then the two ratios that number turns on — 0.714 assumed off another face, 0.762 measured on this one — and what the gap cost: every size 6% too large until the face itself was measured on a render rather than the capture. Which is also what finally makes the failed match matter on screen rather than being a footnote. You cannot read a size off a screenshot without knowing the face, so the shot that admits it could not name one now says what that admission cost. 0.353 stays in `data.ts`, where a reader can see it in context; the film never prints it. CAP joins data.ts, cited to the canvas README's "What the file said, and what the renders corrected". Face grows 46 -> 54 frames so the four beats settle six frames before the fade; every start after it shifts by eight and the film is 428 frames (14.3 s). Starts are computed from CUT, so only meta.json and the README table needed the new numbers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzapdP1QwGhzCF5AA4Dwqq
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Adds
motion/, the video half of whatcanvas/already does for the artboards: drop a folder intomotion/src/<bucket>/<slug>/and it becomes a Remotion composition named after the folder, shows up in the studio, and — once rendered — plays on the canvas. No registry, no build step, nothing to keep in step with the filesystem.Ships with sixteen templates —
spatial-gallery, plus fifteen taken off a single brand film, one motion effect each — plus Remotion's official agent skills vendored under.agents/.Why
motion/sits where it doesremotion.config.tspoints the public dir at../mockups, so a composition reaches a board withstaticFile("canvases/luma-ios/01-guest-top.html")and a photo withstaticFile("canvases/apple-photos/assets/photos/01-minerva-1.jpg"). Motion consumes mockups, which is why it is a sibling ofmockups/rather than a folder inside it — the same relationshipcanvas/has.The bit worth reviewing:
meta.jsonTwo bundlers have to agree on one composition's box. rspack builds the compositions; Vite builds the canvas and needs that box to size the preview. JSON is the one format both parse, so
meta.jsonis a sidecar rather than a field in the TSX:Discovery in
Root.tsxusesrequire.context— that is rspack's API.import.meta.globis Vite-only and does not exist in that bundler.On the canvas
Rendered mp4s land on a Motion page (
?canvas=motion), each looping at 478pt wide — the artboards' own column pitch, so a video lines up with the boards it was made from.server.fs.allowopens the repo root because?urlassets outsidecanvas/are served, unlike the boards'?rawHTML which is inlined at transform time.The mp4 in
out/is committed (2.1 MB): it is the only way to see an asset without running the project, it is what the canvas plays, and it is what makes a diff reviewable.tools/motionkit.pyThe motion half of
refkit.py, applying the repo's measurement rule to time.probe,flow,sheet,compare,selftest— ffmpeg piped straight into numpy, no temp files.flowis the one that earns its keep.spatial-gallerylooks like a slow camera move and is in fact two momentum flicks with a dead hold between them, which is a thing you measure, not a thing you notice.Verification
npx remotion compositionslistsspatial-gallery 30 1080x864 120from folder structure alonemotionkit probeprintsmeta.jsonbyte-identical to the committed filemotionkit flowon our own render: peak 84.8 px/frame at f9, total 1706×570, axis 18.5° — against the reference's ~80 at f8–12, 1630×582, 19.7°motionkit selftestpasses (phase correlation recovers known shifts exactly)lint/test/buildclean;motiontsccleanThe measurements taken off the third-party reference clip are committed; the clip itself is not — the same split
.gitignorealready makes forref-*.htmlboards.The fifteen
One 68-second film taken apart into the fifteen things it actually does:
mesh-gradientcount-up,word-cascade,text-marker,word-swap,word-gridorb-bloom,bokeh-orbit,particle-form,depth-flythroughcard-stack,focus-pull,lens-revealpill-expand,logo-outroThey exist to be cut together, and that is the whole reason for the interface: every one takes
durationInFramesas a prop rather than reading it fromuseVideoConfig. Inside a<Sequence>,useVideoConfig().durationInFramesstill reports the composition's length, so a template that trusts it stretches wrong the moment it is placed in a cut.useDuration(override?)takes the prop and falls back to the composition, which keeps each one scrubbable on its own in the studio. Every one is also fully settled well before its own last frame, so two can butt against each other with nothing to hide the seam.src/lib/holds what more than one of them needs — the palette with the frame each colour came off, the two faces, the timing helpers, the mesh ground, the orb. It sits outsideRoot.tsx'srequire.contexton purpose, so a shared module never becomes an empty entry in the studio sidebar.Three things that cost real time
radial-gradient(circle 55% at ...)is invalid CSS — a circle's radius may not be a percentage — and one invalid layer silently drops the wholebackgroundshorthand. The mesh painted no blobs at all, with no error anywhere, until this becameellipse R% R%.width/heightdoes not survive being a flex item next to something wide: it shrinks on the cross axis and the sphere comes out an egg. Theflex: noneguard lives inOrb.tsx, not at the one call site that showed the bug.loadFont()fires 126 requests for Inter at the head of every render.fonts.tspins weights and subset.All three are now comments where they bit.
What is not reproduced
The motion — timings, easings, geometry, palette, type scale. Not the film's wordmark and not its ad copy: templates carry this repo's own strings and
logo-outrosets the mark to "Motion", which is a rights line and also the point, since a template with someone else's copy baked in is a screenshot. The clip stays out of the repo, as.gitignorealready requires.src/lib/README.mdcarries the shared provenance and the commands to reproduce any number in it. Each template's own README carries its measurements, its props, and its deviations.More motionkit
swatch <clip> <frame> --grid WxHarea-averages a frame into cells — the shape of a gradient, as hexswatch <clip> <frame> --crop W:H:X:Ycensuses one region at full resolution — the exact hex of a chipsheet --from/--tocuts a contact sheet down to one shot, which is how "the gradient moves" became forty frames you can fit a curve toBoth are how every number above was read.
Then measured again, and two of them were the wrong effect
Everything above was reviewed once against contact sheets. A second pass —
render all fifteen, tile them, and go back to the clip for anything that
looks off rather than tuning by eye — found that looking once is not enough.
word-swapwas replicating an effect the film does not contain. Itcrossfaded two words past each other, blurred and lifting, over a line held
still: a plausible thing to build from memory. Sampled at one frame per tile,
f213–f228 does something else entirely — a gap opens between two words over
six frames, the word changes on one frame with no blur and no dissolve,
and a sphere fades up into the space that was just made for it. The hard cut
is the effect, and it is rewritten around that. The template that blurs
words past each other is
word-cascade; this one is deliberately not that.depth-flythroughscattered its orbs. Each got a random x and y inside awanderbox — the obvious way to build "a crowd of spheres", and it piledthem into a blob with no depth in it. In f1480–f1545 the orbs sit on one
straight line passing to the left of the camera, converging on x 0.56 /
y 0.50, each 0.63 the size of the one in front.
wanderand itsseedaregone;
vanishandoffsetreplace them, and screen x now runs through thesame perspective divide as the size, which is what turns a handful of circles
into a corridor.
Four more numbers that were guesses rather than measurements:
orb-bloomtobokeh-orbitcountparticle-formrimdepth-flythroughbubble paddingpx * kempx * kinside a box whose type already scaled byk, so it scaled twice and the near bubble came out all paddingorb-bloom's chips now also leave over the last ten frames, which is wherethe film cuts, so the template has an end state and not just a hold — the
whole point of the set being composable.
Eleven README reference ranges were wrong by 50–400 frames. They are corrected
against the clip, and each README now carries the
motionkit sheetcommandthat reproduces its own sheet, so the next reader can check the claim instead
of taking it.
Each of the two rewrites is written up in its own README's Deviations,
mistake first. A template whose numbers came from memory looks exactly like
one whose numbers came from a measurement, right up until someone re-derives
them; saying which is which is the only thing that makes the difference legible.
Verification, this pass
npx tsc --noEmitclean; all sixteen compositions render via./render.sh(5.6 MB of CRF-28 previews total)spatial-galleryproduced a byte-identical mp4, which is the determinism rule holdingThe
filmsbucket, and what assembling the set foundsrc/films/brand-film/is the fifteen templates cut back into one 43.2 spiece, in the source film's own shot order. It is a shot list and nothing else
— no animation, easing, colour or copy — and it overrides exactly one prop
per shot,
durationInFrames. That is the composability claim the whole set wasbuilt to make, exercised for the first time: inside a
<Sequence>,useVideoConfig().durationInFramesstill reports the film's 1296 frames, so atemplate that read its length from there would time itself against the film
while occupying 84 frames of it. Nothing dissolves anywhere in the cut; every
template opens settled and settles again before its own last frame.
Then the reason to assemble it. Comparing the cut against the source shot by
shot showed twelve of the fifteen templates had been authored 1.3x to 2.4x
too small, and every one of them looked right on its own. A template scrubbed
alone has no scale — it fills its own frame whatever size its contents are.
word-cascade0.19 → 0.247,bokeh-orbit0.13 → 0.3,orb-bloom0.045 →0.09,
focus-pull0.13 → 0.29,logo-outro0.062 → 0.107,card-stackandpill-expandwhole boxes at ×1.35, and so on; each traced to a named frame.Three of the corrections were not sizes:
orb-bloom's chips are upright (theitalic belongs to
word-grid), its bullets are flat pale discs rather than litorbs, and
card-stack's cards are one key light washing across the row insteadof eight differently coloured cards.
motionkit extentThe measurement is committed rather than left in a scratchpad, because twelve
prop tables now cite it. It subtracts a wide Gaussian from a frame and boxes
what survives, so type and hard chrome register while gradients and bokeh do
not; at the same
--width, a 2880-wide reference and a 1920-wide render givedirectly comparable fractions of frame.
comparecannot answer this question — it wants a frame-aligned pair, and43.2 s against 68.4 s in a different shot order is not one.
selftestcoversextentwith a bar of ink on a full-frame gradient: it must find the bar andnot the ramp, within the halo that subtracting a blur necessarily leaves.
src/films/brand-film/README.mdcarries the cut table, the per-shot ratiotable above, and the honest list of what is deliberately not matched — the
reference's live footage and product UI (most of the missing 25 s), the
wordmark and ad copy, Instrument Serif's missing weight, and our lens-free
render.
🤖 Generated with Claude Code