⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
cinetic
Direct, build and render premium cinematic motion design from code — launch films, product and feature videos, looping feature animations for landing pages and social, logo stings and reveals, UI walkthroughs, app demos, kinetic type, teasers, trailers, intros and promo clips. Use this whenever the user wants any video, animation, motion graphic or animated demo made with Remotion, HyperFrames, HTML/GSAP or React, or asks to storyboard, time, score, render, polish or critique one — even if they only say "make a video about X", "animate this feature" or "we need a launch clip". It supplies concept and copy discipline, type and colour taste that defers to a supplied brand, a beat-locked timeline that drives both picture and a code-made soundtrack, choreography and transition craft, real product UI in motion, film-grade finishing (true motion blur, no banding, BT.709, sync-checked mux) and a measured self-critique loop on contact sheets and renders.
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
Not yet listed on ClawHub or SkillsMP
// README
cinetic (cinema + kinetic) is an agent skill that makes your coding agent direct a short film instead of arranging animation components.
- Idea first. Before any code, the agent writes three concepts through different lenses, tests them, and picks one story device the film owns.
- 273 techniques, drawn at random. A library of measured moves in 14 categories, each with a build recipe and timing at 60 fps.
pick.pydraws the film's plan, weighted for its format and energy, so two films don't share the same fade-up, push-in and end card. - Its own brand. The look comes from three personality words, so a calm notes app and a fast CI tool don't end up looking alike.
- Calm, with words in fragments. Copy arrives a few words at a time, split around what the product does ("here is the newest" → the product → its name), with readable holds that drift instead of freezing and stretches of pure picture between. The fragment grammar comes from a frame-by-frame study of eight professional films; a crisp profile is there when a brief asks for energy.
- One beat grid, measured sound.
timeline.tsdrives the picture, the soundtrack and the QA scripts, so every hit lands on its frame. The effects come from an earshot sound kit: sounds designed in code and rendered with measured sync points, one soft sound per text fragment, one clean element on the lockup. Typing, clicks and ticks follow measured norms of top-tier launch films: soft, short and mostly left to the picture. - Finished and measured. True motion blur, no banding, BT.709 and a sync-checked mux, then pixel forensics, an audio-sync audit, contact sheets and critic passes against a ship gate.
- No generated look. Hard bans, enforced by a linter and the critics, keep out the looks that mark a video as generated. A brand you supply always wins.
It builds with Remotion (React) by default, or HyperFrames (HTML and GSAP).
Quick start · Gallery · Technique library · Install · Requirements · Use · How it works · Hard bans · What's inside · Evaluation
Quick start
npx skills add Leonxlnx/cinetic
Then ask your agent for a film, for example: "Make a 20-second launch teaser with sound for our database branching tool. Invent the brand." It works with Claude Code, Codex, Cursor and the other agents the skills CLI supports.
Before the first film, check your machine with a first test render. It needs Node 22+, ffmpeg 6+, Python 3.11+ and a headless Chromium; see Requirements.
Gallery
Quire Streaks. A 12 s vertical video for Reels and TikTok, in the brand the brief supplied. A blue thread stitches each day's pages into the Streaks screen, one rising note per stitch. On Sunday at 11:41 pm the thread hangs loose until one tap on Log pulls it tight, and the week gathers into a quire, the bookbinding word behind the name. Download MP4 (1080x1920, sound, 1.4 MB)
Ledgerly Auto-Split. A 10 s silent loop for a landing page. The bill starts split evenly at 53.55 each; Auto-Split tags each line with who ate it, fans the receipt out into four and trims each copy to that friend's dishes, shared ones divided only among the people who shared them. "Jonas only had salad." Download MP4 (1920x1080, loop, 2 MB)
Halden. A 6 s logo sting for a calm note-taking app, mixed at −16 LUFS, plus four ProRes 4444 alpha versions (held and cleared, for light and dark footage). The app's text cursor settles into a dot, and the h draws its arch over it like a fermata, the musical sign for "hold". Download MP4 (1920x1080, sound, 0.3 MB)
All four films were made by an agent with development snapshots of cinetic 1.0.0, in evaluation rounds 6 (Kiln) and 7 (the others), from the one-paragraph briefs below, the same briefs used in the evaluation. The brands are invented, except where the brief supplied one. Clicking a GIF downloads its MP4. The films live on the repo's media branch, so installs stay small.
Kiln launch teaser
Invent the brand for Kiln (a build cache that makes CI runs finish in seconds) and make a 25-second launch teaser with sound for our announcement on X. It should feel like a well-funded company shipped it, not a template.
Quire Streaks vertical video
Our reading-tracker app Quire is shipping Streaks next week. Make a 12-second vertical product video (1080x1920, for Instagram Reels and TikTok, with sound) showing how a reading streak builds over a week. Use our brand: ink #1B1B1F, paper #FBF7F0, accent #3E7BFA, typeface Manrope.
Ledgerly feature loop
Make a 10-second looping feature animation for the landing page of Ledgerly, a shared-expenses app. It should show the new Auto-Split feature splitting a dinner bill between four friends. 1920x1
// HOW IT'S BUILT
// FILES
146 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
skills/cinetic/SKILL.md66 KBa457f27b5528truncatedupstream ↗
Showing the inlined prefix. The rest is in the upstream file.
--- name: cinetic description: Direct, build and render premium cinematic motion design from code — launch films, product and feature videos, looping feature animations for landing pages and social, logo stings and reveals, UI walkthroughs, app demos, kinetic type, teasers, trailers, intros and promo clips. Use this whenever the user wants any video, animation, motion graphic or animated demo made with Remotion, HyperFrames, HTML/GSAP or React, or asks to storyboard, time, score, render, polish or critique one — even if they only say "make a video about X", "animate this feature" or "we need a launch clip". It supplies concept and copy discipline, type and colour taste that defers to a supplied brand, a beat-locked timeline that drives both picture and a code-made soundtrack, choreography and transition craft, real product UI in motion, film-grade finishing (true motion blur, no banding, BT.709, sync-checked mux) and a measured self-critique loop on contact sheets and renders. license: MIT compatibility: Needs Node 22+, ffmpeg 6+ with libx264, Python 3.11+ with numpy, scipy, soundfile, pyloudnorm, opencv-python and librosa (fonttools, brotli and uharfbuzz for outlined logos; pillow for gradient PNGs), and Chromium or Chrome Headless Shell for rendering; earshot 0.3+ (optional, in its own virtualenv) renders the sound kit and listens to the soundtrack. Works with Remotion 4 (React) or HyperFrames (HTML and GSAP). --- # cinetic You are directing a short film, not animating a web page. Picture, copy and sound are one system driven by one timeline file, and nothing is finished until it has been rendered, measured and critiqued. - **Engines.** Remotion 4.0.529 (React, frame-driven) is the default. HyperFrames 0.8.79 (HTML plus a paused GSAP timeline) is the alternative. The craft is engine-independent; §7 lists the rules that differ per engine. - **Numbers.** Every number here is a proven default from shipped work: a starting point that you can move away from when you have a stated reason. It is not a law of nature. - **Brand.** When the user supplies a brand (colours, fonts, logo, footage, tone), the brand wins. The taste defaults cover the parts you have to invent. - **Paths.** Paths such as `scripts/render.sh` work from the skill root and also inside a film project, because `scripts/new-film.sh` copies every script into the project. Every script except the internal helpers `brand-svg.ts` (which `brand-kit.sh` calls) and `hf-preflight.cjs` (which `hf-finish.sh` runs) prints `--help`. - **Worked example.** `references/worked-example.md` walks through Tessel, a 33 s launch film at 1920×1080 and 60 fps, and shows each rule below in use, including the mistakes. Read it once before your first film. ## The bar The target is a film people watch twice: one idea the product owns, told in pictures, where every frame looks chosen and nothing is there to fill space. - **Substance before polish.** Show the specific, clever thing this product does, the thing a simpler product wouldn't. Flawless motion around a generic claim still loses to a rough film that makes the viewer think "oh, that's smart". - **Its own look.** Every brand gets a visual language derived from its name, its product and its personality, inside the Hard bans below. Restraint means no decoration; it does not mean one minimal house style for everything. - **Ultra clean and smooth.** Clean, modern frames, with creative ideas and choreography. Every move is eased, weighted and smooth at 60 fps: no jitter, no pops and no effects standing in for ideas. - **Visual, not narrated.** The idea reads with the sound off; the words confirm what the picture already said. - **Weight and stillness.** Motion has mass, arrives on the beat and then rests, so the fast moments land. - **Sound that makes the picture feel better:** every hit is caused by something you can see. - **Restraint over decoration.** When in doubt, remove. - **Proof, not impression.** The process below exists because none of this can be judged in the editor or the Studio: you only know once you have watched the actual render and measured it. ## Hard bans When you invent the look, none of these appear, ever. They are the fastest tells of generated work, and this skill exists to make films that don't look generated. `lint-film.mjs` checks the tokens and styles for them, and the critics check the frames. - **Words:** - eyebrow or kicker labels above a headline; - stacked taglines, and "Introducing…"; - full sentences dumped at once: copy arrives as short fragments, one after another, with picture between them (§4, "Text flow"); - text walls, and random filler text: decorative mono captions, fake metrics, labels nobody needs, lorem ipsum. Every word on screen earns its place. - **Type:** serif typefaces, and italic or oblique styles, including a skew that fakes one. Use one clean, modern sans, plus a mono for code or numbers if the product needs one. - **Colour:** - orange, amber, beige, cream, tan or sand, as an accent, a paper or a glow; - neon: very bright, saturated accents, glows, bloom and halos; - purple, violet or indigo, and purple-to-blue or any multi-hue gradient; - glassmorphism. - **Decoration:** - emoji and stock icons; - sparkles standing for "AI", particles, confetti, lens flares and code rain; - bouncy overshoot on anything that isn't landing, and any overshoot or per-letter trick on copy (only the one hero word may move letter by letter). What remains is plenty: ink and paper (light or dark, neutral or cool), one accent with a meaning from any hue family outside the banned ones (red, green, teal, blue, yellow), one sans, and motion that is ultra clean and smooth, eased, weighted, motion-blurred and free of jitter. The creativity goes into the idea, the device and the choreography, not into effects. If the user supplies a brand that includes one of these (their serif wordmark, their orange), the brand wins, because it is their identity rather than a default. Record it in `BRIEF.md` so the critics don't flag it, and mark the lines that set it with `// cinetic:brand-supplied <what>` so the lint accepts them. ## 1. Formats and defaults Choose a row first. It sets the length, the density and the list of files to deliver. The recipe for each format is in `references/formats.md`. The numbers are the calm pace, the default; a brief that asks for energy (a teaser, a drop, a hype cut) opts into the crisp profile (§5), which is faster and allows a little more text (fragments up to 5 words, up to 10 visible) but still never a sentence dumped at once. | Format | Length | fps | Grid (BPM) | Story beats | Words: total · new per fragment · visible at once | Sound | Deliverables | |---|---|---|---|---|---|---|---| | Launch film / teaser | 20–45 s | 60 | 100 (crisp 120) | 6–8 per 30 s (crisp 8–10) | 15–25, cap 30 · ≤ 4 · ≤ 8 (≤ 5 in replace mode) | full score + SFX | 16:9 master, 9:16 and 1:1 re-layouts, poster, brand kit if invented | | Product / feature video, update film | 8–30 s | 60 | 100 | 1 capability per 8–10 s | ≤ 12 · ≤ 4 · ≤ 8 | UI SFX; calm: effects only at −18 LUFS (a light bed only when the brief wants a groove) | master, poster | | Feature loop | 4–15 s, whole bars | 60 (GIF 25) | any; whole bars | 3–5 | ≤ 6, reads muted · ≤ 4 · ≤ 4 | optional | MP4, WebM, GIF, seamless seam | | Logo sting | 3–8 s | 60 | 100–120 | 2–4 | name + ≤ 3 · ≤ 3 · name + 3 | one tuned hit + tail | MP4, ProRes 4444 alphas (held and cleared), poster, brand kit if invented | | UI walkthrough | 20–90 s | 60 | 90–100 | 1 chapter per 2–4 bars | ≤ 5 per caption · ≤ 4 · one caption | bed + UI SFX | master, SRT captions | Use 60 fps whenever UI, text or the camera moves. At 30 fps slow drifts visibly step and fast moves strobe, and 24 fps is never right for UI. A GIF is the one exception: export it at 25 fps (or 50) from the 60 fps master, because GIF frame delays are whole hundredths of a second and 30 fps plays 11% fast. ## 2.skills/cinetic/assets/hyperframes-starter/fonts/README.md2.6 KB1978e2a90d78upstream ↗
# fonts/ Every face the film uses ships here as a local `.woff2`, declared with `@font-face` in the file that uses it (inside the `<template>` for an act in `compositions/`). Why local: - HyperFrames embeds a small set of common families and silently aliases other well-known system names to one of them, so a family you name but don't ship can render as a different face with no warning. - A hosted web-font link is fetched at build time and fails when the render runs offline. - `hyperframes lint` reports `font_family_without_font_face` for any family it cannot resolve. The starter uses one variable sans, Geist, as a stand-in: its `@font-face` block in `index.html` carries a `cinetic:placeholder` marker, and `lint-film.mjs` fails until you choose the family for this brand's personality (`references/brand-and-color.md` §2 in the skill) and delete the marker. `npm install` runs `setup.mjs`, which copies the face here from its `@fontsource-variable/*` package: ``` fonts/geist-latin-wght-normal.woff2 <- node_modules/@fontsource-variable/geist/files/ ``` To swap the family, or to add a mono (for code or tabular numbers): 1. `npm i -D @fontsource-variable/schibsted-grotesk` (a sans from the table in `references/brand-and-color.md` §2, or the brand's package; the file is `files/<name>-latin-wght-normal.woff2`). 2. Add a line to `COPIES` in `setup.mjs`, e.g. `['node_modules/@fontsource-variable/schibsted-grotesk/files/schibsted-grotesk-latin-wght-normal.woff2', 'fonts/schibsted-grotesk-latin-wght-normal.woff2']`, then run `node setup.mjs`. 3. Add its `@font-face` block and use the family name literally in `font-family` (lint cannot see through `var(...)`). When `node_modules` is shared with other projects (`new-film.sh --link-modules`), `npm i` would write into that shared folder. Use `node scripts/add-font.mjs <name>` instead of steps 1–2: it fetches the package with `npm pack` into a temp folder, copies the upright latin and latin-ext `.woff2` files and the licence into `fonts/`, and prints the `@font-face` blocks for step 3. When you invent the look the family is a sans (serif and italic are hard bans). For brand-supplied fonts, which may be anything, convert them to `.woff2` (for example `fonttools ttLib.woff2 compress Brand.ttf`, which needs the `brotli` module) and put them here directly. A variable font needs one `@font-face` with a weight range (`font-weight: 100 900`); static fonts need one block per weight. Keep each family's licence file next to its fonts when you hand the project on. `bash scripts/hf-finish.sh` refuses to render when an `@font-face` `url(...)` points at a missing file, because the fallback face would otherwise ship silently.skills/cinetic/scripts/accumulate.py19 KB0aa7cbe2f342upstream ↗
#!/usr/bin/env python3 """Temporal motion blur by accumulation: average each output frame's sub-frames in float, quantize once. Reads a sub-frame render and writes the blurred master. Two modes: samples mode the FilmSub stream, where output frame f owns groups[f] consecutive sub-frames (from scripts/measure-speed.py, the same JSON FilmSub was rendered with) --uniform K a plain render at fps*K (HyperFrames, or any engine without adaptive sampling); each output frame averages the ceil(K*shutter/360) sub-frames centred on it Why outside the browser: compositing samples in Chromium quantizes every layer to 8 bits, which posterizes soft gradients, tints light greys and darkens the frame a little per sample. Here the decode is 16-bit, the average is float32, the BT.709 limited-range conversion is done here in float (it matches swscale to 0.01 of a code value), and the frame is quantized exactly once, straight to the yuv420p planes x264 encodes. Rounding is plain, so a flat field (paper, a solid stage, a flat card, moving or fading) comes out as one code value everywhere and encodes exactly. A +-1 LSB triangular (TPDF) dither is added only where banding can form, in smooth ramps (a dark pool of light, a blurred soft edge, a moving gradient), in every frame whether or not it moved, and with one fixed noise pattern, so a still shot stays identical from frame to frame and the encoder can keep the dither instead of smoothing it back into contours. Why not let ffmpeg convert the 16-bit average: swscale always adds an 8x8 ordered dither when it reduces a >8-bit source to 8 bits, so a flat paper whose value falls between two codes (228.31 here) becomes a fixed 228/229 checker on every frame. x264 keeps that pattern in some blocks and flattens others, frame by frame, and a 25x stretch shows horizontal streaks and block smears of +-1-2 levels over the whole background (references/finishing.md, section 5). Usage: python3 scripts/accumulate.py out/sub.mp4 out/samples.json out/picture.mp4 [--crf 14] python3 scripts/accumulate.py out/sub.mp4 out/samples.json out/picture.mp4 --start 600 --count 120 python3 scripts/accumulate.py renders/x4.mp4 out/picture.mp4 --uniform 4 [--shutter 240] python3 scripts/accumulate.py 'frames/*.png' out/picture.mp4 --uniform 4 --in-fps 240 Size and fps come from ffprobe (image sequences need --in-fps). Options: --crf (14), --preset (slow), --x264-params and --tune (none), --10bit (yuv420p10le, High 10), --start/--count (a range of film frames, for excerpt and per-act renders), --dither LSB (1.0; 0 = off), --dither-mask gradient|changed|all (where the dither goes; default gradient), --dither-frames N (1: one fixed pattern), --fps (override output fps). Width and height must be even (4:2:0). Exit 0 on success, 1 on failure (stream shorter/longer than the samples expect, or the encoder failed). """ import argparse import glob import json import math import os import subprocess import sys import time from collections import OrderedDict from fractions import Fraction import numpy as np try: import cv2 except ImportError: # the gradient mask falls back to numpy (slower) cv2 = None # Extra x264 options for the master: none. With the planes quantized here a flat field is exact at # any setting, and on a dark dithered gradient aq-mode=3, tune film/grain and deblock -1:-1 moved # the banding error by 3% or less while aq-mode=3 cost 66% more bits on a flat sting (finishing.md §5). DEFAULT_X264 = '' # BT.709 luma weights; swscale's rgb48 carries 8-bit code v as v*256 KR, KB = 0.2126, 0.0722 KG = 1.0 - KR - KB # Where the dither goes (luma px; the chroma planes use half): a pixel is dithered when its 5x5 # neighbourhood is not flat (range > FLAT_EPS code values; a slow 1-code-per-200-px ramp still # counts) and its 9x9 neighbourhood holds no edge (range <= RAMP_MAX; steeper than ~1 code per px # nothing can band). Flat fields, including the paper right next to a hairline, are rounded. FLAT_R, FLAT_EPS = 2, 0.01 EDGE_R, RAMP_MAX = 4, 8.0 def parse_args(): ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0], formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('paths', nargs='+', help='SRC SAMPLES OUT, or SRC OUT with --uniform') ap.add_argument('--uniform', type=int, help='K: the source was rendered at fps*K; average uniformly') ap.add_argument('--shutter', type=float, default=240, help='shutter angle for --uniform (default 240)') ap.add_argument('--crf', type=float, default=float(os.environ.get('ACCUM_CRF', 14))) ap.add_argument('--preset', default='slow') ap.add_argument('--tune', help='x264 tune (none by default; see references/finishing.md section 5)') ap.add_argument('--x264-params', default=os.environ.get('ACCUM_X264', DEFAULT_X264), help='extra x264 options, e.g. "aq-mode=3" (default: none)') ap.add_argument('--10bit', dest='tenbit', action='store_true', help='encode yuv420p10le (High 10)') ap.add_argument('--start', type=int, default=0, help='first film frame in the stream (samples mode)') ap.add_argument('--count', type=int, help='number of film frames in the stream (samples mode)') ap.add_argument('--dither', type=float, default=1.0, help='TPDF dither amplitude in output LSB (0 = off)') ap.add_argument('--dither-mask', choices=('gradient', 'changed', 'all'), default='gradient', help='gradient: smooth ramps only, flat fields and edges rounded (default); changed: every ' 'pixel the shutter changed (the older rule); all: every pixel') ap.add_argument('--dither-all', action='store_true', help='same as --dither-mask all') ap.add_argument('--dither-frames', type=int, default=1, help='distinct noise patterns, cycled (1 = one fixed pattern, which a static shot keeps exactly)') ap.add_argument('--fps', type=float, help='output fps (default: source fps, or source fps / K)') ap.add_argument('--in-fps', type=float, help='source fps for image sequences') ap.add_argument('--quiet', action='store_true') a = ap.parse_args() if a.dither_all: a.dither_mask = 'all' if a.uniform: if len(a.paths) == 3: a.src, _, a.out = a.paths elif len(a.paths) == 2: a.src, a.out = a.paths else: ap.error('--uniform takes SRC OUT') a.samples = None if a.uniform < 1: ap.error('--uniform K must be >= 1') else: if len(a.paths) != 3: ap.error('want SRC SAMPLES OUT (or SRC OUT --uniform K)') a.src, a.samples, a.out = a.paths return a def fail(msg): sys.stderr.write(f'accumulate.py: {msg}\n') sys.exit(1) def source_args(src, in_fps): """ffmpeg input args for a video file, a printf pattern or a glob of images.""" if os.path.isdir(src): imgs = sorted(glob.glob(os.path.join(src, '*.png')) + glob.glob(os.path.join(src, '*.jpg'))) if not imgs: fail(f'no .png/.jpg frames in {src}') src = os.path.join(src, '*' + os.path.splitext(imgs[0])[1]) if any(c in src for c in '*?['): if not in_fps: fail('image sequences need --in-fps') return ['-framerate', str(in_fps), '-pattern_type', 'glob', '-i', src], sorted(glob.glob(src))[0], True if '%' in src: if not in_fps: fail('image sequences need --in-fps') return ['-framerate', str(in_fps), '-i', src], src % 0 if os.path.exists(src % 0) else src % 1, True if not os.path.exists(src): fail(f'missing source {src}') return ['-i', src], src, False def probe(path, count_frames): cmd = ['ffprobe', '-v', 'error', '-select_streams', 'v:0'] if count_frames: cmd.append('-count_packets') cmd += ['-show_entries', 'stream=width,height,avg_frame_rate,r_frame_rate,nb_read_packets,color_space,color_range', '-of', 'json', path] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode or not json.loads(r.stdout).get('streams'): fail(f'cannot probe {path}: {r.stderr.strip()}') s = json.loads(r.stdout)['streams'][0] fps = None for k in ('avg_frame_rate', 'r_frame_rate'): try: f = Fraction(s.get(k, '0/0')) if f > 0: fps = float(f) break except (ValueError, ZeroDivisionError): pass n = int(s['nb_read_packets']) if s.get('nb_read_packets') else None return s['width'], s['height'], fps, n def ycbcr_matrix(): """3x3 from swscale rgb48 units to BT.709 limited-range Y, Cb, Cr in 8-bit code units (no offsets).""" m = np.array([[KR, KG, KB], [-KR / (2 * (1 - KB)), -KG / (2 * (1 - KB)), 0.5], [0.5, -KG / (2 * (1 - KR)), -KB / (2 * (1 - KR))]], np.float32) return m * (np.array([[219], [224], [224]], np.float32) / (255.0 * 256.0)) M709 = ycbcr_matrix() OFF = np.array([16, 128, 128], np.float32) def to_ycbcr(rgb48): """float32 (H, W, 3) in swscale rgb48 units -> Y (H, W), Cb, Cr (H/2, W/2): BT.709 limited range in 8-bit code units (float). Chroma is the 2x2 mean, which is what swscale does here.""" h, w = rgb48.shape[:2] if cv2 is not None: yuv = cv2.transform(rgb48, np.hstack([M709, OFF[:, None]])) y, cb, cr = cv2.split(yuv) half = (w // 2, h // 2) return y, cv2.resize(cb, half, interpolation=cv2.INTER_AREA), cv2.resize(cr, half, interpolation=cv2.INTER_AREA) yuv = (rgb48.reshape(-1, 3) @ M709.T).reshape(h, w, 3) + OFF c = yuv[..., 1:].reshape(h // 2, 2, w // 2, 2, 2).mean(axis=(1, 3)) return np.ascontiguousarray(yuv[..., 0]), c[..., 0], c[..., 1] def local_range(p, r): """max - min of plane p over a (2r+1)^2 window.""" if cv2 is not None: k = np.ones((2 * r + 1, 2 * r + 1), np.uint8) return cv2.dilate(p, k) - cv2.erode(p, k) hi, lo = p.copy(), p.copy() for ax in (0, 1): a, b = hi.copy(), lo.copy() for s in range(1, r + 1): for sh in (s, -s): np.maximum(a, np.roll(hi, sh, axis=ax), out=a) np.minimum(b, np.roll(lo, sh, axis=ax), out=b) hi, lo = a, b return hi - lo def half_any(mask): return mask[0::2, 0::2] | mask[1::2, 0::2] | mask[0::2, 1::2] | mask[1::2, 1::2] def main(): a = parse_args() t0 = time.time() in_args, probe_path, is_seq = source_args(a.src, a.in_fps) W, H, src_fps, n_src = probe(probe_path, not is_seq) if is_seq: src_fps = a.in_fps n_src = len(sorted(glob.glob(a.src))) if any(c in a.src for c in '*?[') else None if not src_fps: fail('cannot tell the source fps; pass --in-fps') # plan: for each output frame, the list of source indices it averages if a.uniform: K = a.uniform if n_src is None: fail('cannot count source frames; pass a video file') n_out = n_src // K n = max(1, min(K, math.ceil(K * a.shutter / 360 - 1e-9))) h = (n - 1) // 2 plan = [[min(max(f * K - h + i, 0), n_src - 1) for i in range(n)] for f in range(n_out)] out_fps = a.fps or src_fps / K expected = n_src desc = f'uniform K={K}: {n} of every {K} sub-frames, centred ({a.shutter:g} deg)' else: try: groups = json.load(open(a.samples))['groups'] except (OSError, ValueError, KeyError) as e: fail(f'cannot read groups from {a.samples}: {e}') groups = groups[a.start:a.start + a.count if a.count else None] if not groups: fail(f'no groups at --start {a.start} in {a.samples}') plan, j = [], 0 for g in groups: g = max(1, int(g)) plan.append(list(range(j, j + g))) j += g expected = j n_out = len(plan) out_fps = a.fps or src_fps desc = f'{n_out} frames from {expected} sub-frames (film frames {a.start}-{a.start + n_out - 1})' if n_src is not None and n_src != expected: before = sum(max(1, int(g)) for g in json.load(open(a.samples))['groups'][:a.start]) fail(f'{a.src} has {n_src} frames but {a.samples} expects {expected} sub-frames for film frames ' f'{a.start}-{a.start + n_out - 1}: render FilmSub with the same --props file over sub-frames ' f'{before}-{before + expected - 1}') FRAME = W * H * 3 if W % 2 or H % 2: fail(f'{W}x{H}: 4:2:0 needs an even width and height') # decode at 16 bits with the file's own matrix/range and accurate chroma; swscale maps 8-bit # code v to ~v*256 in rgb48, and to_ycbcr() maps it back the same way dec = subprocess.Popen(['ffmpeg', '-v', 'error', *in_args, '-vf', 'scale=in_color_matrix=auto:in_range=auto:flags=accurate_rnd+full_chroma_int', '-f', 'rawvideo', '-pix_fmt', 'rgb48le', '-'], stdout=subprocess.PIPE) # the planes are quantized here and piped as they are, labelled BT.709 limited range by setparams # so that ffmpeg converts nothing (a lossless encode decodes to the same planes). Unlabelled, # ffmpeg 7.1 and newer insert a scaler that converts them from an assumed matrix (the colours # shift: PSNR Y 22 dB in that lossless test) and, as the stream takes its tags from the frames, # write no primaries or transfer. pix = 'yuv420p10le' if a.tenbit else 'yuv420p' gain, top, dtype = (4.0, 1023, '<u2') if a.tenbit else (1.0, 255, np.uint8) os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True) x264 = (['-tune', a.tune] if a.tune else []) + (['-x264-params', a.x264_params] if a.x264_params else []) enc = subprocess.Popen(['ffmpeg', '-v', 'error', '-y', '-f', 'rawvideo', '-pix_fmt', pix, '-s', f'{W}x{H}', '-r', f'{out_fps:g}', '-i', '-', '-vf', 'setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv', '-c:v', 'libx264', '-preset', a.preset, '-crf', f'{a.crf:g}', *x264, '-pix_fmt', pix, '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709', '-color_range', 'tv', '-movflags', '+faststart', a.out], stdin=subprocess.PIPE) got_idx = [-1] def read_frame(): buf = bytearray(FRAME * 2) view = memoryview(buf) got = 0 while got < len(buf): k = dec.stdout.readinto(view[got:]) if not k: return None got += k got_idx[0] += 1 return np.frombuffer(buf, dtype='<u2').reshape(H, W, 3) # TPDF noise in output LSB, one set per plane; a few frames of it, cycled rng = np.random.default_rng(7) def tpdf(shape): return (rng.random(shape, dtype=np.float32) + rng.random(shape, dtype=np.float32) - np.float32(1)) * np.float32(a.dither) bank = [(tpdf((H, W)), tpdf((H // 2, W // 2)), tpdf((H // 2, W // 2))) for _ in range(max(1, a.dither_frames))] \ if a.dither > 0 else None def quantize(p, noise, where): p = p * np.float32(gain) if gain != 1 else p if noise is not None and where is not None: p = p + (noise if where is True else np.where(where, noise, np.float32(0))) return np.clip(np.floor(p + np.float32(0.5)), 0, top).astype(dtype) cache = OrderedDict() # source index -> frame (uniform mode windows may overlap or repeat) def get(idx): while got_idx[0] < idx: fr = read_frame() if fr is None: enc.stdin.close() enc.wait() fail(f'sub-frame stream ended at frame {got_idx[0] + 1}, expected {expected} ({desc})') cache[got_idx[0]] = fr return cache[idx] def next_frame(): fr = read_frame() if fr is None: enc.stdin.close() enc.wait() fail(f'sub-frame stream ended at frame {got_idx[0] + 1}, expected {expected} ({desc})') return fr acc = np.zeros((H, W, 3), dtype=np.float32) changed = np.zeros((H, W, 3), dtype=bool) dithered = 0.0 last_log = time.time() for f, idxs in enumerate(plan): # samples mode streams (a frame's sub-frames are consecutive and used once); uniform mode # windows can repeat an index at the ends, so it keeps a small cache src = (lambda i: next_frame()) if not a.uniform else get first = src(idxs[0]) acc[:] = first blurred = len(idxs) > 1 if blurred: changed[:] = False for i in idxs[1:]: x = src(i) acc += x changed |= x != first acc /= len(idxs) planes = to_ycbcr(acc) noise = bank[f % len(bank)] if bank is not None else (None, None, None) if bank is None: wheres = (None, None, None) elif a.dither_mask == 'all': wheres = (True, True, True) elif a.dither_mask == 'changed': # the older rule: every pixel the shutter changed ch = changed[..., 0] | changed[..., 1] | changed[..., 2] if blurred else np.zeros((H, W), bool) wheres = (ch, half_any(ch), half_any(ch)) else: # gradient: smooth ramps only, in every frame; flat areas and hard edges are rounded wheres = [] for p, r in zip(planes, (1, 0.5, 0.5)): # chroma planes are half size wheres.append((local_range(p, int(FLAT_R * r)) > FLAT_EPS) & (local_range(p, int(EDGE_R * r)) <= RAMP_MAX)) if bank is not None: dithered += float(np.mean(wheres[0])) for p, d, w in zip(planes, noise, wheres): enc.stdin.write(quantize(p, d, w).tobytes()) if a.uniform: lo = plan[f + 1][0] if f + 1 < len(plan) else got_idx[0] + 1 for k in [k for k in cache if k < lo]: del cache[k] if not a.quiet and time.time() - last_log > 10: last_log = time.time() el = time.time() - t0 sys.stderr.write(f' accumulated {f + 1}/{n_out} frames, {el:.0f}s, eta {el / (f + 1) * (n_out - f - 1):.0f}s\n') extra = 0 while read_frame() is not None: # drain (uniform mode drops a partial last group) extra += 1 if a.uniform: extra = 0 enc.stdin.close() rc = enc.wait() dec.stdout.close() dec.wait() if extra: fail(f'sub-frame stream has {extra} more frames than {a.samples} expects ({desc})') if rc: fail(f'encoder failed (exit {rc})') if not a.quiet: sys.stderr.write(f'wrote {a.out}: {desc}, {W}x{H}@{out_fps:g}, crf {a.crf:g} {a.x264_params or "x264 defaults"} ' f'{pix} bt709, dither on {100 * dithered / max(1, n_out):.1f}% of pixels ({a.dither_mask}), ' f'{time.time() - t0:.0f}s\n') print(a.out) if __name__ == '__main__': main()skills/cinetic/scripts/add-font.mjs14 KB163c9edbae5dupstream ↗
#!/usr/bin/env node /** * add-font.mjs: vendor a @fontsource-variable/* family into the film project, without npm install. * * A project made with `new-film.sh --link-modules` shares its node_modules with other projects, so * `npm install <font>` inside it would write into that shared folder (and change every film that * uses it). This script fetches the package with `npm pack` into a temp folder (or reads it from * node_modules when it is already there, read-only), and copies the face into the project: * * Remotion src/brand/fonts/<name>/index.css + the .woff2 files it references + LICENSE; * src/brand/fonts.ts imports './fonts/<name>/index.css' in place of the import it * replaces for that role (the starter's stand-in, or a family vendored before); * src/brand/tokens.ts gets the family in FONT.<role> and * FACES, with every weight clamped to the font's axis. The bundler serves the files, * FontGate waits for them: nothing is fetched at render time. * HyperFrames fonts/<name>-latin[-ext]-wght-normal.woff2 + fonts/<name>-LICENSE, and prints the * @font-face block to paste into index.html. * * Only normal (upright) faces are copied: italics are a hard ban when you invent the look. The * package's own category decides the role: a monospace family becomes FONT.mono, a sans FONT.text; * serif, display and handwriting families are refused unless --brand-supplied (the user's brand * supplied it: record it in BRIEF.md). * * Usage (from the project root, or pass --dir): * node scripts/add-font.mjs <name | @fontsource-variable/name[@version]> [--role text|mono] * [--dir <project>] [--no-tokens] [--brand-supplied] [--dry-run] * e.g. node scripts/add-font.mjs manrope * node scripts/add-font.mjs @fontsource-variable/jetbrains-mono --role mono * Exit codes: 0 vendored, 1 failed (the message says why), 2 bad arguments. */ import { execFileSync } from 'node:child_process'; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; const HELP = readFileSync(new URL(import.meta.url), 'utf8').replace(/\r\n/g, '\n').split('*/')[0].replace(/^#!.*\n\/\*\*?/, '').replace(/^ \* ?/gm, ''); // Files are edited with LF and written back with the line ending they had (a Windows checkout has CRLF). const readText = (p) => { const s = readFileSync(p, 'utf8'); return { text: s.replace(/\r\n/g, '\n'), eol: s.includes('\r\n') ? '\r\n' : '\n' }; }; const writeText = (p, text, eol) => writeFileSync(p, eol === '\n' ? text : text.replace(/\n/g, eol)); // npm is npm.cmd on Windows, which execFile cannot start without a shell (EINVAL / ENOENT) const WIN = process.platform === 'win32'; const npm = (args, opts) => (WIN ? execFileSync('npm', args.map((a) => `"${a}"`), { ...opts, shell: true }) : execFileSync('npm', args, opts)); let tmp = null; // the npm pack folder, removed on every exit const cleanup = () => { if (tmp) rmSync(tmp, { recursive: true, force: true }); tmp = null; }; const die = (msg, code = 1) => { cleanup(); console.error(`add-font: ${msg}`); process.exit(code); }; const args = process.argv.slice(2); let spec = null, role = null, dir = '.', tokens = true, brand = false, dry = false; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '-h' || a === '--help') { console.log(HELP.trim()); process.exit(0); } else if (a === '--role') role = args[++i]; else if (a === '--dir') dir = args[++i]; else if (a === '--no-tokens') tokens = false; else if (a === '--brand-supplied') brand = true; else if (a === '--dry-run') dry = true; else if (a.startsWith('-')) die(`unknown option ${a} (see --help)`, 2); else if (spec) die(`one font per run (got ${spec} and ${a})`, 2); else spec = a; } if (!spec) { console.log(HELP.trim()); die('missing <name | package>', 2); } if (role && !['text', 'mono'].includes(role)) die('--role must be text or mono', 2); dir = resolve(dir); // ---- which package ------------------------------------------------------------------------ const m = /^(?:(@[\w.-]+)\/)?([\w.-]+?)(?:@([\w.^~<>=*-]+))?$/.exec(spec); if (!m) die(`cannot read the package name "${spec}"`, 2); const scope = m[1] || '@fontsource-variable'; const name = m[2]; const pkg = `${scope}/${name}`; if (scope !== '@fontsource-variable') console.warn(`add-font: ${pkg} is not a variable package; one face per weight will be copied as its index.css lists them`); const engine = existsSync(join(dir, 'src/brand')) ? 'remotion' : existsSync(join(dir, 'index.html')) ? 'hyperframes' : null; if (!engine) die(`${dir} is not a cinetic film (no src/brand/ and no index.html); pass --dir`); // ---- get the files: node_modules (read-only) or npm pack into a temp folder ----------------- let root = join(dir, 'node_modules', pkg); if (!existsSync(join(root, 'index.css')) || m[3]) { tmp = mkdtempSync(join(tmpdir(), 'add-font-')); try { const out = npm(['pack', m[3] ? `${pkg}@${m[3]}` : pkg, '--pack-destination', tmp, '--silent'], { cwd: tmp, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim().split(/\r?\n/).pop().trim(); // a relative name from inside the folder: GNU tar reads "C:\..." as a remote host execFileSync('tar', ['-xzf', basename(out)], { cwd: tmp }); } catch (e) { die(`npm pack ${pkg} failed: ${(e.stderr || e.message || '').toString().trim().split('\n').slice(-2).join(' ')}\n` + ' (is the name right? the families are listed at fontsource.org; offline, copy the .woff2 files by hand: references/copy-and-type.md §6)'); } root = join(tmp, 'package'); } try { const meta = existsSync(join(root, 'metadata.json')) ? JSON.parse(readFileSync(join(root, 'metadata.json'), 'utf8')) : {}; const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; const css = readFileSync(join(root, 'index.css'), 'utf8'); const blocks = [...css.matchAll(/(\/\*[^*]*\*\/\s*)?@font-face\s*\{[^}]*\}/g)].map((b) => b[0]).filter((b) => !/font-style:\s*(italic|oblique)/.test(b)); if (!blocks.length) die(`${pkg} has no upright @font-face in index.css`); const family = /font-family:\s*['"]([^'"]+)['"]/.exec(blocks[0])?.[1]; const wr = /font-weight:\s*(\d+)(?:\s+(\d+))?/.exec(blocks[0]); const axis = wr ? [Number(wr[1]), Number(wr[2] ?? wr[1])] : [400, 400]; const category = meta.category || 'unknown'; if (['serif', 'display', 'handwriting'].includes(category) && !brand) die(`${family} is a ${category} family: serif, display and script faces are hard bans when you invent the look ` + '(SKILL.md). If the user\'s brand supplies it, rerun with --brand-supplied and record it in BRIEF.md.'); role = role || (category === 'monospace' || / Mono\b/.test(family) ? 'mono' : 'text'); const files = [...new Set(blocks.flatMap((b) => [...b.matchAll(/url\(\s*['"]?\.\/(files\/[^'")]+)['"]?\s*\)/g)].map((u) => u[1])))]; const missing = files.filter((f) => !existsSync(join(root, f))); if (missing.length) die(`${pkg} is missing ${missing.join(', ')}`); const lic = ['LICENSE', 'LICENSE.md', 'OFL.txt'].find((f) => existsSync(join(root, f))); const kb = (fs) => Math.round(fs.reduce((s, f) => s + statSync(join(root, f)).size, 0) / 1024); const say = (s) => console.log(`add-font: ${s}`); if (engine === 'hyperframes') { const pick = files.filter((f) => /-(latin|latin-ext)-[\w-]*normal\.woff2$/.test(f)); const dst = join(dir, 'fonts'); if (!dry) { mkdirSync(dst, { recursive: true }); for (const f of pick) copyFileSync(join(root, f), join(dst, basename(f))); if (lic) copyFileSync(join(root, lic), join(dst, `${name}-LICENSE`)); } say(`${family} ${version} (${category}, weight ${axis.join('-')}): ${pick.map((f) => 'fonts/' + basename(f)).join(', ')} (${kb(pick)} KB)`); console.log(`\npaste into index.html (and use the family name literally in font-family):\n` + pick.map((f) => { const b = blocks.find((x) => x.includes(f)); const range = /unicode-range:\s*([^;]+);/.exec(b)?.[1]; return `@font-face {\n font-family: "${family}";\n src: url("fonts/${basename(f)}") format("woff2");\n font-weight: ${axis.join(' ')};\n font-style: normal;${range ? `\n unicode-range: ${range};` : ''}\n}`; }).join('\n')); cleanup(); process.exit(0); } // ---- Remotion: vendor the CSS and its files ------------------------------------------------ const rel = `fonts/${name}`; const dst = join(dir, 'src/brand', rel); const header = `/* ${family} ${version}, vendored from ${pkg} by scripts/add-font.mjs (upright faces only).\n` + ` Licence: ./LICENSE. Weight axis ${axis.join('-')}. Loaded by src/brand/fonts.ts; FontGate waits for FACES. */\n\n`; if (!dry) { rmSync(dst, { recursive: true, force: true }); mkdirSync(join(dst, 'files'), { recursive: true }); for (const f of files) copyFileSync(join(root, f), join(dst, f)); if (lic) copyFileSync(join(root, lic), join(dst, 'LICENSE')); writeFileSync(join(dst, 'index.css'), header + blocks.join('\n\n') + '\n'); } say(`${family} ${version} (${category}, weight ${axis.join('-')}) -> src/brand/${rel}/ (${files.length} files, ${kb(files)} KB)`); // fonts.ts: import the vendored CSS in place of what it replaces for this role: the starter's // stand-in, or a family vendored here before for the same role (and the stand-in's marker) const fontsTs = join(dir, 'src/brand/fonts.ts'); const { text: ft0, eol: ftEol } = existsSync(fontsTs) ? readText(fontsTs) : { text: '', eol: '\n' }; let ft = ft0; const replaced = []; const drop = (re) => { ft = ft.replace(re, (line) => (replaced.push(line.trim()), '')); }; drop(role === 'mono' ? /^import '@fontsource-variable\/geist-mono(?:\/[^']*)?';[^\n]*\n?/gm : /^import '@fontsource-variable\/geist(?:\/[^']*)?';[^\n]*\n?/gm); drop(new RegExp(`^import '\\./fonts/(?!${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/)[^']+/index\\.css';[^\\n]*\\(${role}\\)[^\\n]*\\n?`, 'gm')); if (role === 'text') ft = ft.replace(/^(?:\/\/\n)?\/\/ cinetic:placeholder[^\n]*\n(?:\/\/(?! cinetic:)[^\n]*\n)*/m, ''); const imp = `import './${rel}/index.css'; // ${family} (${role}), vendored: scripts/add-font.mjs`; if (!ft.includes(`'./${rel}/index.css'`)) ft = ft.replace(/\s*$/, '\n') + imp + '\n'; if (!dry) writeText(fontsTs, ft, ftEol); say(`src/brand/fonts.ts imports ./${rel}/index.css${replaced.length ? ` (replacing ${replaced.map((l) => l.replace(/^import /, '').replace(/;.*$/, '')).join(', ')})` : ''}`); // tokens.ts: FONT.<role> and FACES follow the family; weights clamp to the axis const tokTs = join(dir, 'src/brand/tokens.ts'); if (tokens && existsSync(tokTs)) { const { text: tk0, eol: tkEol } = readText(tokTs); let tk = tk0; const line = new RegExp(`^(\\s*${role}:\\s*)'([^']*)'`, 'm'); const cur = line.exec(tk); if (!cur) die(`src/brand/tokens.ts has no FONT.${role} line to set (set FONT and FACES by hand)`); const old = /"([^"]+)"/.exec(cur[2])?.[1]; const stack = role === 'mono' ? `"${family}", ui-monospace, monospace` : `"${family}", system-ui, sans-serif`; tk = tk.replace(line, `$1'${stack}'`); const clampW = (w) => Math.min(axis[1], Math.max(axis[0], w)); const clamped = []; tk = tk.replace(/^(export const FACES\s*=\s*\[)([^\]]*)\]/m, (all, head, body) => { const faces = [...body.matchAll(/'([^']*)'/g)].map((x) => x[1]); const next = faces.map((f) => { const fm = /^(\d+)\s+(\S+)\s+"([^"]+)"$/.exec(f); if (!fm || fm[3] !== old) return f; const w = clampW(Number(fm[1])); if (w !== Number(fm[1])) clamped.push(`${fm[1]} -> ${w}`); return `${w} ${fm[2]} "${family}"`; }); if (!next.some((f) => f.endsWith(`"${family}"`))) next.push(`400 16px "${family}"`); return `${head}${[...new Set(next)].map((f) => `'${f}'`).join(', ')}]`; }); if (role === 'text') tk = tk.replace(/^\/\/ cinetic:placeholder - the family is[^\n]*\n(?:\/\/(?! cinetic:)[^\n]*\n)*/m, ''); // TYPE weights outside the axis would silently fall back to the nearest one: say so const outside = [...tk.matchAll(/weight:\s*(\d+)/g)].map((x) => Number(x[1])).filter((w) => w < axis[0] || w > axis[1]); if (!dry) writeText(tokTs, tk, tkEol); say(`src/brand/tokens.ts: FONT.${role} = '${stack}'${old ? ` (was "${old}")` : ''}; FACES follow it` + (clamped.length ? ` (weights clamped to the axis: ${clamped.join(', ')})` : '')); if (role === 'text' && outside.length) console.warn(`add-font: TYPE uses weight ${[...new Set(outside)].join(', ')}, outside ${family}'s axis ${axis.join('-')}: pick weights inside it`); // the mono stand-in stays loaded until a mono replaces it: say so when nothing uses it const monoUsed = readdirSync(join(dir, 'src'), { recursive: true }).some((f) => /\.(tsx?)$/.test(f) && !/^brand[\\/]/.test(f) && /\bFONT\.mono\b/.test(readFileSync(join(dir, 'src', f), 'utf8'))); if (role === 'text' && /Geist Mono/.test(tk) && !monoUsed) say('note: the starter\'s mono stand-in (Geist Mono) is still in fonts.ts and FACES, and nothing outside src/brand uses FONT.mono: ' + 'remove it (the import, its FACES entry and the package) or vendor this film\'s mono with --role mono'); } // package.json: the starter's stand-in dependency for this role is no longer used const pj = join(dir, 'package.json'); const standPkg = role === 'mono' ? '@fontsource-variable/geist-mono' : '@fontsource-variable/geist'; if (existsSync(pj) && !dry) { const p = JSON.parse(readFileSync(pj, 'utf8')); const stillUsed = readdirSync(join(dir, 'src'), { recursive: true }).some((f) => /\.(tsx?|css)$/.test(f) && readFileSync(join(dir, 'src', f), 'utf8').includes(`'${standPkg}'`)); if (p.dependencies?.[standPkg] && !stillUsed) { delete p.dependencies[standPkg]; writeFileSync(pj, JSON.stringify(p, null, 2) + '\n'); say(`package.json: dropped ${standPkg} (the stand-in)`); } } console.log(`next: choose the weights and tracking in TYPE for this family (references/copy-and-type.md §6), then npm run check && npm run stills`); } finally { cleanup(); }skills/cinetic/scripts/audio/kit.py37 KB73aa79f8e2edupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/audio/listen.py13 KB3a1dacf5bdd5upstream ↗
#!/usr/bin/env python3 """ listen.py: earshot's ears on the film's soundtrack, in one process so its models load once: the timeline (every sound event with tags, loudness, the beat grid, and how each cue in out/cues.json is answered: offset in ms, OK / near / no sound), the annotated sound sheet, and the critique scorecard against measured launch films. Writes out/qa/listen.json and out/qa/sound_sheet.png. With --kit it listens to the kit instead: one take per role, earshot's description and a forced choice between the role's own description and cheap-synth alternatives (a laser zap, a video-game blip, an 8-bit sound, a rising synth whip) -> out/qa/kit_listen.json; a role an alternative outranks is flagged. Usage (from the project root): python3 scripts/audio/listen.py [--audio public/audio/soundtrack.wav] [--cues out/cues.json] [--video out/preview.mp4] [--out out/qa] [--fast] python3 scripts/audio/listen.py --kit audio/kit.json [--takes audio/kit] [--json out/qa/kit_listen.json] --fast skip the critique's slower model passes (AST labels, realness); the timeline still runs --python PY the interpreter that has earshot (else score.json sfx.python, EARSHOT_PYTHON, this interpreter, earshot on PATH: kit.py finds it) It runs itself again inside earshot's interpreter when that is another one. Without earshot, or when its models cannot load, it writes the JSON with "listened": false and the reason, and exits 0: listening informs the review and never blocks a build. Read the model-free numbers first (cue sync offsets, loudness, the beat grid, the sheet); tags, verdicts and scores are hints from models. Exit codes: 0 written (listened or not); 1 invalid arguments or a missing input file. """ import argparse import json import os import subprocess import sys import time HERE = os.path.dirname(os.path.abspath(__file__)) ROLE_QUERY = { 'tick': 'a light tap or tick', 'tick.crisp': 'a short crisp tick', 'tock': 'a soft wooden knock', 'hit': 'a single clean impact or struck note', 'land': 'a soft low thump', 'land.light': 'a light soft tap', 'pop': 'a soft pop', 'whoosh.short': 'a short soft air whoosh', 'whoosh.long': 'a long air whoosh', 'suck': 'a reverse whoosh', 'riser': 'a rising whoosh', 'drop': 'a deep sub bass drop', 'key': 'a mechanical keyboard key press', 'key.space': 'a keyboard space bar press', 'key.return': 'a keyboard enter key press', 'click': 'a soft click', 'click.confirm': 'a soft plucked note', 'snap': 'a short firm knock', 'swell': 'a reverse swell', 'bell': 'a plucked or struck bell note', 'text': 'a soft plucked or tapped note', } SLOP = ['a cheap synthesizer laser zap', 'a video game blip', 'an 8-bit chiptune sound', 'a rising synth whip sweep'] def write(path, obj): os.makedirs(os.path.dirname(os.path.abspath(path)) or '.', exist_ok=True) with open(path, 'w', encoding='utf-8', newline='\n') as fh: fh.write(json.dumps(obj, indent=1, ensure_ascii=False, default=str) + '\n') def not_listened(path, why, **extra): rep = dict(listened=False, why=why, **extra) write(path, rep) print(f'listen: not listened: {why}\n wrote {path}') return 0 # ------------------------------------------------------------------------------------------ # inside earshot's interpreter # ------------------------------------------------------------------------------------------ def _soundtrack(a, api, earshot): rep = dict(listened=True, earshot=earshot.__version__, audio=a.audio, video=a.video, cues=a.cues, errors={}) t0 = time.time() tl = None try: tl = api.timeline(a.audio, video=a.video, cues=a.cues) except Exception as e: # noqa: BLE001 (models missing, offline, out of memory: say so, keep going) rep['errors']['timeline'] = f'{type(e).__name__}: {e}' if tl: lo = tl.get('loudness') or {} cues = (tl.get('sync') or {}).get('cues') or {} rep.update(summary=tl.get('summary'), loudness={k: lo.get(k) for k in ('integrated_lufs', 'true_peak_dbtp', 'lra_lu', 'max_short_term', 'digital_silence') if k in lo}, sync=dict(stats=cues.get('stats'), items=[{k: it.get(k) for k in ( 't', 'kind', 'id', 'offset_ms', 'status', 'event', 'event_prominence_db') if k in it} for it in cues.get('items') or []]), events=len(tl.get('events') or []), sections=tl.get('sections'), music={k: (tl.get('music') or {}).get(k) for k in ('bpm', 'pulse_clarity') if k in (tl.get('music') or {})}, transcript=tl.get('text')) try: sh = api.sheet(a.audio, os.path.join(a.out, 'sound_sheet.png'), video=a.video, cues=a.cues, tl=tl) rep['sheet'] = (sh or {}).get('png') or os.path.join(a.out, 'sound_sheet.png') except Exception as e: # noqa: BLE001 rep['errors']['sheet'] = f'{type(e).__name__}: {e}' try: cr = api.critique(a.video or a.audio, video=a.video, use_ast=not a.fast, use_realness=not a.fast) rep['critique'] = dict(verdict=cr.get('verdict'), score=cr.get('score'), summary=cr.get('summary'), actions=cr.get('actions'), tips=cr.get('tips'), off=[{k: m.get(k) for k in ('metric', 'label', 'value', 'status', 'weight')} for m in cr.get('metrics') or [] if m.get('status') not in ('ok', 'n.a.')]) except Exception as e: # noqa: BLE001 rep['errors']['critique'] = f'{type(e).__name__}: {e}' rep['seconds'] = round(time.time() - t0, 1) if not tl and 'critique' not in rep: rep['listened'] = False rep['why'] = '; '.join(f'{k}: {v}' for k, v in rep['errors'].items()) return rep def _kit(a, api, earshot): with open(os.path.join(a.takes, 'kit.lock.json'), encoding='utf-8') as fh: lock = json.load(fh) rep = dict(listened=True, earshot=earshot.__version__, kit=a.kit, takes=a.takes, bank=lock.get('bank'), roles={}, flagged=[], errors={}) t0 = time.time() for role, r in (lock.get('roles') or {}).items(): if not r or not r.get('takes'): continue f = os.path.join(a.takes, r['takes'][0]['file']) q = ROLE_QUERY.get(role, ROLE_QUERY.get(role.split('.')[0], 'a sound effect')) try: m = api.match(f, q, alts=SLOP) d = api.describe(f) alts = sorted(m.get('alts') or [], key=lambda x: -x['p']) row = dict(sound=r['sound'], file=r['takes'][0]['file'], query=q, verdict=m.get('verdict'), p_vs_alts=m.get('p_vs_alts'), top_alt=alts[0] if alts else None, sounds_like=d.get('summary')) if alts and alts[0]['p'] > (m.get('p_vs_alts') or 0): rep['flagged'].append(f"{role} ({r['sound']}): '{alts[0]['query']}' outranks '{q}' " f"({alts[0]['p']:.2f} vs {m.get('p_vs_alts'):.2f})") rep['roles'][role] = row except Exception as e: # noqa: BLE001 rep['errors'][role] = f'{type(e).__name__}: {e}' rep['seconds'] = round(time.time() - t0, 1) if not rep['roles']: rep['listened'] = False rep['why'] = '; '.join(f'{k}: {v}' for k, v in rep['errors'].items()) or 'the kit has no rendered takes' return rep def inner(a): import earshot from earshot import api rep = _kit(a, api, earshot) if a.kit else _soundtrack(a, api, earshot) write(a.json, rep) return 0 # ------------------------------------------------------------------------------------------ # the outer call: find earshot, run inside it # ------------------------------------------------------------------------------------------ def cli_fallback(es, a): """earshot is only a bare `earshot` command (no interpreter beside it): one CLI call per step.""" rep = dict(listened=True, earshot=es.version, audio=a.audio, video=a.video, cues=a.cues, errors={}, note='earshot CLI calls, one process each (models load per call)') vid = ['--video', a.video] if a.video else [] code, tl, err = es.run_json(['timeline', a.audio, '--cues', a.cues] + vid) if tl: rep.update(summary=tl.get('summary'), loudness=tl.get('loudness'), sync=(tl.get('sync') or {}).get('cues'), transcript=tl.get('text')) else: rep['errors']['timeline'] = err.splitlines()[-1] if err else f'exit {code}' p = es.run(['sheet', a.audio, '--cues', a.cues, '-o', os.path.join(a.out, 'sound_sheet.png')] + vid) if p.returncode == 0: rep['sheet'] = os.path.join(a.out, 'sound_sheet.png') code, cr, err = es.run_json(['critique', a.video or a.audio] + (['--fast'] if a.fast else [])) rep['critique'] = {k: cr.get(k) for k in ('verdict', 'score', 'summary', 'actions', 'tips')} if cr else None if not cr: rep['errors']['critique'] = err.splitlines()[-1] if err else f'exit {code}' return rep def main(argv=None): ap = argparse.ArgumentParser(description="earshot's ears on the soundtrack (or, with --kit, on each kit role).") ap.add_argument('--audio', default='public/audio/soundtrack.wav') ap.add_argument('--cues', default='out/cues.json') ap.add_argument('--video', help='the rendered film (preview or master): cuts and picture sync join the timeline') ap.add_argument('--out', default='out/qa', help='folder for listen.json and sound_sheet.png') ap.add_argument('--fast', action='store_true', help="skip the critique's AST labels and realness") ap.add_argument('--kit', help='listen to this kit (audio/kit.json) instead of the soundtrack') ap.add_argument('--takes', help='the kit\'s rendered takes (default: the kit path without .json)') ap.add_argument('--score', default='audio/score.json') ap.add_argument('--json', help='the report (default out/qa/listen.json, or out/qa/kit_listen.json with --kit)') ap.add_argument('--python', help='the interpreter that has earshot') ap.add_argument('--inner', action='store_true', help=argparse.SUPPRESS) a = ap.parse_args(argv) a.json = a.json or os.path.join(a.out, 'kit_listen.json' if a.kit else 'listen.json') if a.kit: a.takes = a.takes or os.path.splitext(a.kit)[0] if a.inner: return inner(a) sys.path.insert(0, HERE) import kit as K if a.kit: if not os.path.isfile(os.path.join(a.takes, K.LOCK)): return not_listened(a.json, f'no rendered takes in {a.takes} (python3 scripts/audio/kit.py render)') else: for f in (a.audio, a.cues) + ((a.video,) if a.video else ()): if not os.path.isfile(f): print(f'listen.py: {f} not found (npm run cues, npm run audio first)', file=sys.stderr) return 1 score_python = None if os.path.isfile(a.score): try: score_python = (K.read_json(a.score, 'score').get('sfx') or {}).get('python') except K.KitError: pass es, tried = K.find_earshot(a.python, score_python) if es is None: return not_listened(a.json, 'earshot not found', tried=tried, hint=K.INSTALL_HINT) print(f'listen: {es}; models load once (20-60 s)') os.makedirs(a.out, exist_ok=True) if es.python is None: if a.kit: return not_listened(a.json, 'kit listening needs earshot\'s interpreter (set EARSHOT_PYTHON)') write(a.json, cli_fallback(es, a)) else: args = [es.python, os.path.abspath(__file__), '--inner', '--json', a.json, '--out', a.out, '--audio', a.audio, '--cues', a.cues] + (['--video', a.video] if a.video else []) + (['--fast'] if a.fast else []) + ( ['--kit', a.kit, '--takes', a.takes] if a.kit else []) p = subprocess.run(args, capture_output=True, text=True, encoding='utf-8', errors='replace') if p.returncode != 0 or not os.path.isfile(a.json): last = (p.stderr.strip().splitlines() or ['no output'])[-1] return not_listened(a.json, f'earshot failed: {last}', earshot=str(es)) with open(a.json, encoding='utf-8') as fh: rep = json.load(fh) if not rep.get('listened'): print(f"listen: not listened: {rep.get('why')}\n wrote {a.json}") return 0 if a.kit: print(f"listen: {len(rep['roles'])} roles in {rep.get('seconds')} s; " + (f"flagged: {'; '.join(rep['flagged'])}" if rep['flagged'] else 'no role outranked by a cheap-synth alternative')) else: st = (rep.get('sync') or {}).get('stats') or {} k1 = next((k for k in st if k.startswith('hit_within_1')), None) or next((k for k in st if k.startswith('hit_within_4')), None) print(f"listen: {(rep.get('summary') or '').split('. ')[0]}" + (f"; cues answered {100 * st[k1]:.0f}% within one frame, median |offset| {st.get('median_abs_offset_ms')} ms" if k1 else '') + (f"; critique {rep['critique']['verdict']}" if rep.get('critique') else '')) for k, v in rep.get('errors', {}).items(): print(f' {k}: {v}') print(f' wrote {a.json}' + (f" and {rep['sheet']}" if rep.get('sheet') else '')) return 0 if __name__ == '__main__': sys.exit(main())skills/cinetic/scripts/audio/master.py15 KBcf9c56547bf1upstream ↗
#!/usr/bin/env python3 """ master.py: cinetic's mix bus and mastering chain, plus loudness measurement. Chain (finish): 28 Hz high-pass -> mono below 120 Hz -> 2:1 bus compressor above -14 dBFS (5 ms RMS detector, 15 ms attack, 120 ms release) -> soft saturation + air -> loudness passes to the target LUFS, each followed by a true-peak lookahead limiter (4x oversampled detection, 4 ms lookahead, 80 ms release, ceiling 0.77 linear = -2.3 dBFS, which leaves room for AAC overshoot) -> edge fades, always: a 3 ms raised-cosine fade-in from exact zero at sample 0 (a sound already at level on sample 0 clicks and cuts in mid-action) and a raised-cosine fade-out of at least 10 ms (60 ms by default) onto the last sample -> last 480 samples zeroed. score.py imports sidechain_times(), finish() and measure(). Standalone use: python3 master.py mix.wav public/audio/soundtrack.wav --lufs -14 --ceiling 0.77 python3 master.py music.wav ducked.wav --sidechain kick.wav --sc-depth 0.55 --lufs -14 python3 master.py --measure public/audio/soundtrack.wav --json out/qa/loudness.json Exit code 0 when the output meets the gate (|LUFS - target| <= --tol and true peak <= --max-tp), 1 when it does not or on error. The JSON report holds integrated LUFS, true peak (dBTP, 4x oversampled), LRA, the loudest momentary block and its time, limiter gain reduction and tail level. """ import argparse import json import os import sys import numpy as np from scipy import signal from scipy.ndimage import maximum_filter1d, minimum_filter1d SR = 48000 HEAD_FADE = 0.003 # s, raised-cosine fade-in at sample 0, applied after the limiter on every master END_FADE_MIN = 0.010 # s, the shortest fade-out onto the last sample, whatever end_fade asks for # ------------------------------------------------------------------------------------------ # measurement # ------------------------------------------------------------------------------------------ def _stereo(x): x = np.asarray(x, dtype=np.float64) return np.stack([x, x], axis=1) if x.ndim == 1 else x def _kw(x): """BS.1770 K-weighting (48 kHz coefficients): high shelf then RLB high-pass.""" b1, a1 = [1.53512485958697, -2.69169618940638, 1.19839281085285], [1.0, -1.69065929318241, 0.73248077421585] b2, a2 = [1.0, -2.0, 1.0], [1.0, -1.99004745483398, 0.99007225036621] return signal.lfilter(b2, a2, signal.lfilter(b1, a1, x, axis=0), axis=0) def blocks(x, win=0.4, hop=0.1): """Loudness (LUFS) of sliding blocks; 0.4 s = momentary, 3 s = short-term. Returns (t_mid, L).""" y = _kw(_stereo(x)) p = np.cumsum(np.concatenate([np.zeros((1, 2)), y ** 2]), axis=0) w, h = int(win * SR), int(hop * SR) if len(y) < w: return np.array([len(y) / SR / 2]), np.array([-70.0]) starts = np.arange(0, len(y) - w + 1, h) ms = (p[starts + w] - p[starts]) / w L = -0.691 + 10 * np.log10(np.maximum(ms.sum(axis=1), 1e-12)) return (starts + w / 2) / SR, L def integrated(x): import pyloudnorm as pyln return float(pyln.Meter(SR).integrated_loudness(_stereo(x))) def lra(x): """EBU loudness range: 10th-95th percentile of gated 3 s short-term loudness.""" _, st = blocks(x, 3.0, 0.1) st = st[st > -70] if len(st) < 2: return 0.0 rel = 10 * np.log10(np.mean(10 ** (st / 10))) - 20 st = st[st > rel] return float(np.percentile(st, 95) - np.percentile(st, 10)) if len(st) > 1 else 0.0 def true_peak_db(x): up = signal.resample_poly(_stereo(x), 4, 1, axis=0) return float(20 * np.log10(np.max(np.abs(up)) + 1e-12)) def measure(x): x = _stereo(x) tm, m = blocks(x, 0.4, 0.1) k = int(np.argmax(m)) tail = x[-int(0.05 * SR):] return { 'lufs': round(integrated(x), 2), 'true_peak_db': round(true_peak_db(x), 2), 'sample_peak_db': round(float(20 * np.log10(np.max(np.abs(x)) + 1e-12)), 2), 'lra': round(lra(x), 2), 'momentary_max': round(float(m[k]), 2), 'momentary_max_t': round(float(tm[k]), 3), 'tail_50ms_db': round(float(20 * np.log10(np.sqrt(np.mean(tail ** 2)) + 1e-12)), 1), 'last_480_zero': bool(np.all(x[-480:] == 0)), 'seconds': round(len(x) / SR, 4), } # ------------------------------------------------------------------------------------------ # dynamics # ------------------------------------------------------------------------------------------ def sidechain_times(hits, n, depth=0.55, tau=0.14, attack=0.003, length=0.6): """Duck gain (n, 1) from (t_seconds, weight) pairs: each hit pulls the music down by depth*weight with a 3 ms attack and recovers with time constant tau.""" g = np.ones(n) a = int(attack * SR) ramp = 0.5 - 0.5 * np.cos(np.linspace(0, np.pi, max(a, 1))) seg_t = np.arange(int(length * SR)) / SR shape = np.exp(-seg_t / tau) shape[:a] *= ramp for t, w in hits: i = int(round(t * SR)) if i >= n: continue j = min(n, i + len(shape)) s0 = max(0, -i) g[max(i, 0):j] = np.minimum(g[max(i, 0):j], 1 - depth * w * shape[s0:j - i]) return g[:, None] def sidechain_key(key, depth=0.55, tau=0.14, hop=48): """Duck gain (n, 1) that follows a key signal (a kick stem): instant attack, release tau.""" k = np.abs(_stereo(key)).max(axis=1) nb = int(np.ceil(len(k) / hop)) v = np.pad(k, (0, nb * hop - len(k))).reshape(nb, hop).max(axis=1) v = v / (np.percentile(v[v > 0], 99.9) if np.any(v > 0) else 1.0) rel = np.exp(-hop / (tau * SR)) e = np.empty(nb) cur = 0.0 for i, x in enumerate(v.tolist()): cur = x if x > cur else cur * rel e[i] = cur g = 1 - depth * np.clip(e, 0, 1) return np.interp(np.arange(len(k)), np.arange(nb) * hop + hop / 2, g)[:, None] def mono_below(x, fc=120): """Sum the stereo image to mono below fc so the low end is centred and translates to phones.""" x = _stereo(x) mid = x.mean(axis=1, keepdims=True) side = signal.sosfilt(signal.butter(2, fc, 'high', fs=SR, output='sos'), (x[:, :1] - x[:, 1:]) / 2, axis=0) return np.concatenate([mid + side, mid - side], axis=1) def bus_comp(x, thr=-14.0, ratio=2.0, attack=0.015, release=0.12, rms=0.005, hop=48): """Glue compressor: true RMS detector (rms seconds), gain computed at a 1 ms control rate. Returns (y, gain_reduction_db per sample).""" x = _stereo(x) det = signal.sosfilt(signal.butter(1, 1 / (2 * np.pi * rms), 'low', fs=SR, output='sos'), np.mean(x ** 2, axis=1)) nb = int(np.ceil(len(x) / hop)) d = np.pad(det, (0, nb * hop - len(det)), mode='edge').reshape(nb, hop).max(axis=1) over = np.maximum(0, 10 * np.log10(np.maximum(d, 1e-12)) - thr) target = -over * (1 - 1 / ratio) att, rel = np.exp(-hop / (attack * SR)), np.exp(-hop / (release * SR)) g = np.empty(nb) cur = 0.0 for i, v in enumerate(target.tolist()): cur = v + (cur - v) * (att if v < cur else rel) g[i] = cur gr = np.interp(np.arange(len(x)), np.arange(nb) * hop + hop / 2, g) return x * (10 ** (gr / 20))[:, None], gr def limiter(x, ceiling=0.77, look=0.004, release=0.08, hop=16): """True-peak lookahead limiter. Detection on a 4x oversampled copy catches inter-sample overs; the gain is computed per 16-sample block (min over neighbouring blocks, so the interpolated gain never exceeds what any sample needs), attacks over the lookahead and releases with time constant `release`. Returns (y, gain per sample).""" x = _stereo(x) n = len(x) up = np.abs(signal.resample_poly(x, 4, 1, axis=0)).max(axis=1) a = np.pad(up, (0, max(0, 4 * n - len(up))))[:4 * n].reshape(n, 4).max(axis=1) la = max(hop, int(look * SR)) peak = maximum_filter1d(a, size=la, origin=-(la // 2), mode='nearest') # max over [i, i+la) need = np.minimum(1.0, ceiling / np.maximum(peak, 1e-9)) nb = int(np.ceil(n / hop)) gb = np.pad(need, (0, nb * hop - n), mode='edge').reshape(nb, hop).min(axis=1) k = max(3, la // hop) win = np.hanning(k + 2)[1:-1] win /= win.sum() gb = np.minimum(gb, np.convolve(np.pad(gb, (len(win) // 2, len(win) - 1 - len(win) // 2), mode='edge'), win, mode='valid')) rel = np.exp(-hop / (release * SR)) out = np.empty(nb) cur = 1.0 for i, v in enumerate(gb.tolist()): cur = v if v < cur else v + (cur - v) * rel out[i] = cur out = minimum_filter1d(out, 3, mode='nearest') g = np.minimum(np.interp(np.arange(n), np.arange(nb) * hop + hop / 2, out), need) return x * g[:, None], g def loudness_passes(x, target=-14.0, ceiling=0.77, passes=3, tol=0.05, max_passes=8): """Normalize to target LUFS then limit; repeat (limiting lowers loudness) until within tol.""" g = np.ones(len(x)) for p in range(max_passes): x = x * 10 ** ((target - integrated(x)) / 20) x, g = limiter(x, ceiling) if p + 1 >= passes and abs(integrated(x) - target) <= tol: break return x, g def glue(x, drive=1.15, air=0.25): """Soft saturation and a gentle high shelf (air).""" x = np.tanh(x * 0.9 * drive) / np.tanh(drive) if air: x = x + signal.sosfilt(signal.butter(1, 2500, 'high', fs=SR, output='sos'), x, axis=0) * air return x def edge_fades(x, head=HEAD_FADE, tail=END_FADE_MIN): """Raised-cosine fade-in over `head` s from exact zero at sample 0, and fade-out over `tail` s to zero on the last sample. Run after the limiter, so nothing raises the edges again.""" x = _stereo(x).copy() n = len(x) for d, rising in ((head, True), (tail, False)): k = min(n, int(round(d * SR))) if k > 1: w = 0.5 - 0.5 * np.cos(np.pi * np.arange(k) / (k - 1)) # 0 -> 1, raised cosine if rising: x[:k] *= w[:, None] else: x[-k:] *= w[::-1, None] return x def finish(mix, lufs=-14.0, ceiling=0.77, mono_hz=120, comp=True, drive=1.15, air=0.25, hp_hz=28, fade=0.0, end_fade=0.06, tail_zero=480): """Full chain for a summed mix. Returns (master, info) where info has the limiter trace.""" x = _stereo(mix) if hp_hz: x = signal.sosfilt(signal.butter(2, hp_hz, 'high', fs=SR, output='sos'), x, axis=0) if mono_hz: x = mono_below(x, mono_hz) gr = np.zeros(len(x)) if comp: x, gr = bus_comp(x) if drive: x = glue(x, drive, air) if fade and fade > 0: t = np.arange(len(x)) / SR x = x * (np.clip((len(x) / SR - 0.03 - t) / fade, 0, 1) ** 2)[:, None] x, g = loudness_passes(x, lufs, ceiling) x = edge_fades(x, HEAD_FADE, max(end_fade or 0.0, END_FADE_MIN)) if tail_zero: x[-tail_zero:] = 0 lim_db = -20 * np.log10(np.maximum(g, 1e-9)) return x, {'limiter_gr_db': lim_db, 'comp_gr_db': -gr} def gate(report, target, tol, max_tp): fails = [] if abs(report['lufs'] - target) > tol: fails.append(f"integrated {report['lufs']} LUFS not within {target}+-{tol}") if report['true_peak_db'] > max_tp: fails.append(f"true peak {report['true_peak_db']} dBTP above {max_tp}") if not report['last_480_zero']: fails.append('last 480 samples are not digital zero') return fails # ------------------------------------------------------------------------------------------ # CLI # ------------------------------------------------------------------------------------------ def _read(path): import soundfile as sf x, sr = sf.read(path, always_2d=True, dtype='float64') if x.shape[1] == 1: x = np.repeat(x, 2, axis=1) x = x[:, :2] if sr != SR: from math import gcd g = gcd(SR, sr) x = signal.resample_poly(x, SR // g, sr // g, axis=0) return x def main(argv=None): ap = argparse.ArgumentParser(description='Master a WAV to target loudness, or measure one (--measure).') ap.add_argument('input', help='input WAV (any rate; resampled to 48 kHz)') ap.add_argument('output', nargs='?', help='output WAV (48 kHz, 24-bit)') ap.add_argument('--measure', action='store_true', help='only measure the input') ap.add_argument('--lufs', type=float, default=-14.0, help='target integrated loudness (default -14)') ap.add_argument('--ceiling', type=float, default=0.77, help='limiter ceiling, linear (default 0.77)') ap.add_argument('--tol', type=float, default=0.5, help='gate tolerance in LU (default 0.5)') ap.add_argument('--max-tp', type=float, default=-1.5, help='gate: max true peak dBTP (default -1.5)') ap.add_argument('--sidechain', metavar='KEY.wav', help='duck the input from this key signal first') ap.add_argument('--sc-depth', type=float, default=0.55) ap.add_argument('--sc-tau', type=float, default=0.14) ap.add_argument('--mono-hz', type=float, default=120, help='mono below this frequency; 0 disables') ap.add_argument('--no-comp', action='store_true', help='skip the bus compressor') ap.add_argument('--no-glue', action='store_true', help='skip saturation and air') ap.add_argument('--fade', type=float, default=0.0, help='quadratic fade-out over the last N seconds') ap.add_argument('--tail-zero', type=int, default=480, help='samples zeroed at the end (default 480)') ap.add_argument('--json', metavar='PATH', help="write the report JSON here ('-' for stdout)") a = ap.parse_args(argv) try: x = _read(a.input) if a.measure: rep = measure(x) fails = gate(rep, a.lufs, a.tol, a.max_tp) else: if not a.output: ap.error('output path required unless --measure') if a.sidechain: key = _read(a.sidechain) key = np.pad(key, ((0, max(0, len(x) - len(key))), (0, 0)))[:len(x)] x = x * sidechain_key(key, a.sc_depth, a.sc_tau) y, info = finish(x, a.lufs, a.ceiling, a.mono_hz, not a.no_comp, 0 if a.no_glue else 1.15, 0 if a.no_glue else 0.25, fade=a.fade, tail_zero=a.tail_zero) import soundfile as sf os.makedirs(os.path.dirname(os.path.abspath(a.output)), exist_ok=True) sf.write(a.output, y.astype(np.float32), SR, subtype='PCM_24') rep = measure(y) rep['limiter_max_gr_db'] = round(float(info['limiter_gr_db'].max()), 2) rep['output'] = a.output fails = gate(rep, a.lufs, a.tol, a.max_tp) rep['pass'] = not fails rep['fails'] = fails except Exception as e: # noqa: BLE001 print('error:', e, file=sys.stderr) return 1 if a.json == '-': print(json.dumps(rep, indent=1)) elif a.json: os.makedirs(os.path.dirname(os.path.abspath(a.json)), exist_ok=True) json.dump(rep, open(a.json, 'w'), indent=1) print(f"{'PASS' if not fails else 'FAIL'} {rep['lufs']} LUFS TP {rep['true_peak_db']} dBTP " f"LRA {rep['lra']} LU loudest {rep['momentary_max']} LUFS-M at {rep['momentary_max_t']}s" + ''.join(f'\n - {f}' for f in fails)) return 0 if not fails else 1 if __name__ == '__main__': sys.exit(main())skills/cinetic/scripts/audio/score.py113 KBf7926335b7c6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/audio/synth.py40 KB5134970c0968upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/av-audit.py50 KB1e5d6222388bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/brand-kit.sh4.2 KB8ce4b7a7aa85upstream ↗
#!/usr/bin/env bash # brand-kit.sh: export an invented brand as files: SVG and PNG mark and lockups, favicon sizes, # a social avatar and, for X, a header image. # # Usage (from the project root): # bash scripts/brand-kit.sh [--out out/deliver/brand] [--x-header] [--avatar light|dark|accent] # [--name TEXT] [--font FILE.woff2] # # It writes into --out (default out/deliver/brand/): # mark-light.svg, mark-dark.svg the mark for light and dark grounds (src/brand/Mark.tsx) # lockup-light.svg, lockup-dark.svg mark + name, the name outlined (scripts/brand-svg.ts); when the # mark is part of the word, export LockupSvg from src/brand/Mark.tsx # and it is written as is (and make BrandLockup draw the same) # lockup-light.png, lockup-dark.png transparent, trimmed to the ink plus a small margin # mark-16.png, mark-32.png favicon sizes, for light grounds # mark-512.png, mark-1024.png transparent, for light grounds; -dark variants too # avatar.png 400 x 400 on the brand's ground (--avatar, default light) # x-header.png --x-header: 1500 x 500 # from the Brand stills in src/Root.tsx (BrandMark, BrandLockup, BrandAvatar, BrandHeader). The film's # own lockup geometry is used: the mark as tall as the name's ink ascent, a 0.3 gap, one baseline. # Check the 16 px mark and the avatar's circle crop by eye before you ship. # # Needs: npx with @remotion/cli, python3 with numpy and opencv, and for the SVG lockups fonttools, # brotli and uharfbuzz. Exit 0 = every file written, 1 = a step failed. set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) usage() { sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit "${1:-0}"; } die() { echo "brand-kit.sh: $*" >&2; exit 1; } step() { echo "[brand-kit] $*" >&2; } OUT=out/deliver/brand; XHEADER=0; AVATAR=light; SVGARGS=() while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage 0 ;; --out) OUT=${2:?}; shift ;; --x-header) XHEADER=1 ;; --avatar) AVATAR=${2:?}; shift ;; --name) SVGARGS+=(--name "${2:?}"); shift ;; --font) SVGARGS+=(--font "${2:?}"); shift ;; *) die "unknown option $1 (see --help)" ;; esac shift done [[ -f src/index.ts ]] || die "run it from the project root (no src/index.ts here)" mkdir -p "$OUT" WORK=$(mktemp -d "${TMPDIR:-/tmp}/cinetic-brand.XXXXXX"); trap 'rm -rf "$WORK"' EXIT step "SVG mark and lockups" npx tsx "$SCRIPT_DIR/brand-svg.ts" --out "$OUT" ${SVGARGS[@]+"${SVGARGS[@]}"} >&2 || die "brand-svg.ts failed (see above)" step "bundling" npx remotion bundle src/index.ts --out-dir "$WORK/bundle" --log=error >/dev/null || die "bundle failed" still() { # id out props npx remotion still "$WORK/bundle" "$1" "$2" --image-format=png --props="$3" --log=error >/dev/null || die "still $1 failed" } for s in 16 32 512 1024; do still BrandMark "$OUT/mark-$s.png" "{\"size\":$s,\"ground\":\"light\"}"; done for s in 512 1024; do still BrandMark "$OUT/mark-$s-dark.png" "{\"size\":$s,\"ground\":\"dark\"}"; done for g in light dark; do still BrandLockup "$WORK/lockup-$g.png" "{\"ground\":\"$g\"}"; done still BrandAvatar "$OUT/avatar.png" "{\"ground\":\"$AVATAR\"}" [[ $XHEADER -eq 1 ]] && still BrandHeader "$OUT/x-header.png" "{\"ground\":\"$AVATAR\"}" step "trimming the lockups" python3 - "$WORK" "$OUT" <<'PY' || die "trimming failed" import sys, cv2, numpy as np work, out = sys.argv[1:3] for g in ('light', 'dark'): im = cv2.imread(f'{work}/lockup-{g}.png', cv2.IMREAD_UNCHANGED) if im is None or im.shape[2] != 4: sys.exit(f'lockup-{g}.png has no alpha channel (the still must paint no background)') ys, xs = np.nonzero(im[..., 3] > 0) if not len(xs): sys.exit(f'lockup-{g}.png is empty') m = int(0.06 * (ys.max() - ys.min())) # a small margin, proportional to the lockup y0, y1, x0, x1 = max(0, ys.min() - m), min(im.shape[0], ys.max() + m + 1), max(0, xs.min() - m), min(im.shape[1], xs.max() + m + 1) cv2.imwrite(f'{out}/lockup-{g}.png', im[y0:y1, x0:x1]) print(f' lockup-{g}.png {x1 - x0}x{y1 - y0}', file=sys.stderr) PY step "done: $(ls "$OUT" | tr '\n' ' ')"skills/cinetic/scripts/brand-svg.ts6.5 KB64420c7719c2upstream ↗
/** * brand-svg.ts: the mark and the lockup as SVG files, for a brand the film invented. * * The mark is src/brand/Mark.tsx rendered to static markup (the same component the film animates), * once in ink for light grounds and once in paper for dark ones. The lockup adds the wordmark as * outlined paths (scripts/outline-text.py: shaped with the font's kerning at the display weight and * tracking), set the way the film's lockup is: the mark as tall as the name's ink ascent, a gap of * 0.3 of that height, one shared baseline (references/brand-and-color.md §6). * * When the mark is part of the name (a letter of the word is the mark, or the mark replaces one), a * mark beside a typed name is the wrong lockup. Export `LockupSvg` from src/brand/Mark.tsx instead: a * component that renders the whole lockup as one <svg> with its own viewBox, all outlines (no live * text), taking `ink` and `dot` colours. brand-svg.ts then writes it as is and needs no font. * * Usage (from the project root; scripts/brand-kit.sh calls it): * npx tsx scripts/brand-svg.ts [--out out/deliver/brand] [--name plinth] [--font path.woff2] * The name defaults to COPY 'wordmark' in src/timeline.ts; the font to the first * @fontsource-variable import in src/brand/fonts.ts. Exit 0 written, 1 on any failure. */ import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const args = process.argv.slice(2); const opt = (name: string, dflt?: string) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : dflt; }; const die = (msg: string): never => { console.error(`brand-svg: ${msg}`); process.exit(1); }; const root = process.cwd(); const out = resolve(opt('--out', 'out/deliver/brand')!); const here = dirname(fileURLToPath(import.meta.url)); const main = async () => { const React = await import('react'); const { renderToStaticMarkup } = await import('react-dom/server'); const imp = (p: string) => import(pathToFileURL(join(root, p)).href); const markModule = await imp('src/brand/Mark.tsx'); const { Mark } = markModule; const tokens = await imp('src/brand/tokens.ts'); const { C, TYPE } = tokens; const timeline = await imp('src/timeline.ts'); const markInner = (ink: string) => renderToStaticMarkup(React.createElement(Mark, { size: 100, ink })) .replace(/^<svg[^>]*>/, '') .replace(/<\/svg>$/, ''); const svg = (w: number, h: number, body: string) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w.toFixed(2)} ${h.toFixed(2)}" width="${Math.round(w)}" height="${Math.round(h)}">${body}</svg>\n`; mkdirSync(out, { recursive: true }); // a lockup the brand draws itself (the mark is part of the word): written as is const LockupSvg = markModule.LockupSvg; // ({ ink, dot }) => <svg viewBox=...> if (LockupSvg) { const files: string[] = []; for (const [ground, ink] of [['light', C.ink], ['dark', C.paper]] as const) { writeFileSync(join(out, `mark-${ground}.svg`), svg(100, 100, markInner(ink))); let body = renderToStaticMarkup(React.createElement(LockupSvg, { ink, dot: C.accent })); if (!/^<svg[^>]*viewBox=/.test(body)) die('LockupSvg must render one <svg> with a viewBox'); if (/<text[\s>]/.test(body)) die('LockupSvg renders live <text>; outline the name (scripts/outline-text.py) so the file needs no font'); if (!/xmlns=/.test(body.slice(0, body.indexOf('>')))) body = body.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"'); writeFileSync(join(out, `lockup-${ground}.svg`), body + '\n'); files.push(`mark-${ground}.svg`, `lockup-${ground}.svg`); } console.log(`brand-svg: ${files.join(', ')} -> ${out} (LockupSvg from src/brand/Mark.tsx)`); return; } const name: string = opt('--name') ?? timeline.COPY?.find((c: { id: string }) => c.id === 'wordmark')?.text ?? die('no --name and no COPY entry "wordmark"'); let font = opt('--font'); if (!font) { const src = readFileSync(join(root, 'src/brand/fonts.ts'), 'utf8').replace(/\/\/.*$/gm, ''); const pkg = src.match(/@fontsource-variable\/([\w-]+)/)?.[1] ?? src.match(/fonts\/([\w-]+)\//)?.[1] ?? die('no @fontsource-variable import or vendored font (src/brand/fonts/<name>/) in src/brand/fonts.ts; pass --font'); // a font vendored by scripts/add-font.mjs wins over one in node_modules const vendored = join(root, 'src/brand/fonts', pkg, 'files', `${pkg}-latin-wght-normal.woff2`); font = existsSync(vendored) ? vendored : join(root, 'node_modules/@fontsource-variable', pkg, 'files', `${pkg}-latin-wght-normal.woff2`); } if (!existsSync(font)) die(`font file ${font} not found; pass --font`); const size = 200; // the wordmark's em in SVG units; everything scales together // the lockup as tokens.ts sets it (LOCKUP = { ratio, gap, weight, track }, references/brand-and-color.md §6), // falling back to the display type and the default proportions const L = (tokens as { LOCKUP?: { ratio?: number; gap?: number; weight?: number; track?: number } }).LOCKUP ?? {}; const weight = L.weight ?? TYPE.display.weight; const track = L.track ?? TYPE.display.track; const text = JSON.parse( execFileSync('python3', [join(here, 'outline-text.py'), '--font', font, '--text', name, '--size', String(size), '--weight', String(weight), '--track', String(track)], { encoding: 'utf8' }), ); const M = text.ascent * (L.ratio ?? 1); // mark height = the name's ink ascent x LOCKUP.ratio (as in the film's lockup) const gap = (L.gap ?? 0.3) * M; const pad = 0.25 * M; const files: string[] = []; for (const [ground, ink] of [['light', C.ink], ['dark', C.paper]] as const) { writeFileSync(join(out, `mark-${ground}.svg`), svg(100, 100, markInner(ink))); const w = pad + M + gap + (text.ink[2] - text.ink[0]) + pad; const h = pad + M + text.descent + pad; const base = pad + M; // baseline y const body = `<g transform="translate(${pad.toFixed(2)} ${pad.toFixed(2)}) scale(${(M / 100).toFixed(4)})">${markInner(ink)}</g>` + `<path fill="${ink}" transform="translate(${(pad + M + gap - text.ink[0]).toFixed(2)} ${base.toFixed(2)})" d="${text.d}"/>`; writeFileSync(join(out, `lockup-${ground}.svg`), svg(w, h, body)); files.push(`mark-${ground}.svg`, `lockup-${ground}.svg`); } console.log(`brand-svg: ${files.join(', ')} -> ${out} ("${name}", ${weight} / ${track} em)`); }; main().catch((e) => die(e instanceof Error ? e.message : String(e)));skills/cinetic/scripts/check-sync.py9.3 KBa9a0d54e6ce6upstream ↗
#!/usr/bin/env python3 """Sync and true-peak gate for a muxed film: is the delivered audio where the source WAV says it is? Decodes the audio of the rendered file (after AAC, exactly what a viewer hears), cross-correlates it with the source soundtrack at a few points and fails if any lag exceeds the tolerance (48 samples = 1 ms at 48 kHz). Remotion's own AAC mux leaves ~2048 samples of encoder priming in the stream, so an unchecked mux is typically 2-3 frames late; this catches that and any wrong-file or trimmed mux. It also measures the true peak of the decoded audio (4x oversampled): AAC encoding raises peaks by ~0.5-1 dB, so a WAV that passed before encode can clip after. Sample points: by default 12%, 45% and 85% of the overlap; a point whose reference window is silent moves to the nearest window with signal, so fades and silences never produce false fails. Both signals are high-passed (500 Hz) and the correlation is normalised by the local energy of each candidate window. Without that, a sustained low bass note (one 49 Hz cycle is ~980 samples) or a fading tail makes the raw correlation peak a whole bass cycle away from the true lag. Usage: python3 scripts/check-sync.py out/film.mp4 [public/audio/soundtrack.wav] python3 scripts/check-sync.py out/act3.mp4 public/audio/soundtrack.wav --ref-offset 14.0 python3 scripts/check-sync.py out/film.mp4 --points 4,14,28 --tol 48 --tp-max -1 --json out/qa/sync.json Options: --tol samples (48), --points auto|comma-separated seconds, --window s (2.0), --search s (0.1), --ref-offset s (video t=0 corresponds to this time in the WAV, for excerpt renders), --tp-max dBTP (-1.0), --min-corr (0.5, below this the audio does not match the source), --highpass Hz (500; 0 = broadband), --lufs/--lufs-tol (optional loudness gate), --json FILE, --quiet. Prints JSON on stdout, a readable summary on stderr. Exit 0 = pass, 1 = fail. """ import argparse import json import os import subprocess import sys import numpy as np import soundfile as sf from scipy import signal def parse_args(): ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0], formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('video') ap.add_argument('source', nargs='?', default='public/audio/soundtrack.wav') ap.add_argument('--tol', type=int, default=48, help='max |lag| in samples (default 48 = 1 ms at 48 kHz)') ap.add_argument('--points', default='auto', help="'auto' (12/45/85%%) or seconds, e.g. 4,14,28") ap.add_argument('--window', type=float, default=2.0, help='correlation window, seconds') ap.add_argument('--search', type=float, default=0.1, help='max lag searched, seconds') ap.add_argument('--ref-offset', type=float, default=0.0, help='WAV time that corresponds to video t=0') ap.add_argument('--tp-max', type=float, default=-1.0, help='true-peak ceiling after decode, dBTP') ap.add_argument('--min-corr', type=float, default=0.5, help='min normalised correlation at the best lag') ap.add_argument('--highpass', type=float, default=500.0, help='high-pass both signals at this frequency (Hz) before correlating; 0 = broadband (default 500)') ap.add_argument('--lufs', type=float, help='optional integrated-loudness gate target') ap.add_argument('--lufs-tol', type=float, default=1.0) ap.add_argument('--json', help='also write the report here') ap.add_argument('--quiet', action='store_true') return ap.parse_args() def rms_db(x): return 20 * np.log10(np.sqrt(np.mean(np.square(x))) + 1e-12) if len(x) else -240.0 def ncc(dw, rw): """Normalised cross-correlation of rw slid across dw ('valid' lags): each lag is divided by the energy of the dw segment it covers, so a loud stretch (or a long bass cycle) cannot win on level.""" n = len(rw) c = signal.correlate(dw, rw, mode='valid', method='fft') e = np.concatenate([[0.0], np.cumsum(np.square(dw, dtype=np.float64))]) seg = np.sqrt(np.maximum(e[n:] - e[:-n], 0.0)) return c / (seg * np.linalg.norm(rw) + 1e-12) def main(): a = parse_args() report = {'video': a.video, 'source': a.source, 'pass': False} def say(msg): if not a.quiet: sys.stderr.write(msg + '\n') def done(ok): report['pass'] = bool(ok) out = json.dumps(report, indent=1) print(out) if a.json: os.makedirs(os.path.dirname(os.path.abspath(a.json)), exist_ok=True) with open(a.json, 'w') as fh: fh.write(out + '\n') say('check-sync PASS' if ok else 'check-sync FAIL') sys.exit(0 if ok else 1) for p in (a.video, a.source): if not os.path.exists(p): report['error'] = f'missing file: {p}' say(report['error']) done(False) ref, sr = sf.read(a.source, always_2d=True, dtype='float32') r = subprocess.run(['ffmpeg', '-v', 'error', '-i', a.video, '-map', '0:a:0', '-f', 'f32le', '-ac', '2', '-ar', str(sr), '-'], capture_output=True) if r.returncode or not r.stdout: report['error'] = 'no decodable audio stream in the video: ' + r.stderr.decode(errors='replace').strip()[:300] say(report['error']) done(False) dec = np.frombuffer(r.stdout, dtype=np.float32).reshape(-1, 2) ref_m = ref.mean(axis=1) if ref.shape[1] > 1 else ref[:, 0] dec_m = dec.mean(axis=1) off = int(round(a.ref_offset * sr)) ref_m = ref_m[off:] if a.highpass > 0: # Same zero-phase filter on both: removes the bass that makes lag estimates periodic, and # cannot shift one signal against the other. sos = signal.butter(4, a.highpass, btype='highpass', fs=sr, output='sos') hp_ref = signal.sosfiltfilt(sos, ref_m).astype(np.float32) hp_dec = signal.sosfiltfilt(sos, dec_m).astype(np.float32) # Fall back to broadband if the soundtrack has almost nothing above the cut-off. if rms_db(hp_ref) > rms_db(ref_m) - 40: ref_m, dec_m = hp_ref, hp_dec report['highpass_hz'] = a.highpass else: report['highpass_hz'] = 0 else: report['highpass_hz'] = 0 overlap = min(len(ref_m), len(dec_m)) / sr report.update(sample_rate=sr, decoded_seconds=round(len(dec_m) / sr, 4), reference_seconds=round(len(ref_m) / sr, 4), ref_offset=a.ref_offset) search = int(a.search * sr) win = min(a.window, max(0.25, overlap / 5)) n_win = int(win * sr) if overlap * sr < n_win + 2 * search: report['error'] = f'overlap too short to measure ({overlap:.2f}s)' say(report['error']) done(False) lo_t, hi_t = a.search, overlap - win - a.search # window starts that leave room for the search if a.points == 'auto': wanted = [lo_t + f * (hi_t - lo_t) for f in (0.12, 0.45, 0.85)] else: wanted = [min(max(float(x), lo_t), hi_t) for x in a.points.split(',') if x.strip()] # windows with signal: reference RMS above -50 dBFS; move a silent point to the nearest loud one step = 0.25 starts = np.arange(lo_t, hi_t + 1e-9, step) loud = [t for t in starts if rms_db(ref_m[int(t * sr):int(t * sr) + n_win]) > -50] points, worst, weak = [], 0, False if not loud: report['error'] = 'reference is silent everywhere in the overlap; nothing to correlate' say(report['error']) done(False) used = set() for t in wanted: tt = t if rms_db(ref_m[int(t * sr):int(t * sr) + n_win]) <= -50: tt = min(loud, key=lambda x: abs(x - t)) if round(tt, 2) in used: continue used.add(round(tt, 2)) s0 = int(tt * sr) rw = ref_m[s0:s0 + n_win] dw = dec_m[s0 - search:s0 + n_win + search] c = ncc(dw, rw) k = int(np.argmax(c)) lag = k - search corr = float(c[k]) worst = max(worst, abs(lag)) weak |= corr < a.min_corr points.append({'t': round(float(tt), 3), 'lag_samples': lag, 'lag_ms': round(lag / sr * 1000, 3), 'corr': round(corr, 4)}) say(f' t={tt:6.2f}s lag {lag:+d} samples ({lag / sr * 1000:+.2f} ms) corr {corr:.3f}') report.update(points=points, worst_lag_samples=worst, tol_samples=a.tol) sync_ok = worst <= a.tol and not weak if weak: say(f' weak correlation (< {a.min_corr}): the muxed audio does not match {a.source}') say(f'sync {"OK" if sync_ok else "FAIL"}: worst {worst} samples (tol {a.tol})') tp = float(20 * np.log10(np.max(np.abs(signal.resample_poly(dec, 4, 1, axis=0))) + 1e-12)) tp_ok = tp <= a.tp_max report.update(true_peak_dbtp=round(tp, 2), tp_max=a.tp_max, true_peak_ok=tp_ok, sync_ok=sync_ok) say(f'true peak {tp:.2f} dBTP ' + ('OK' if tp_ok else f'FAIL (above {a.tp_max} dBTP: lower the limiter ceiling)')) lufs_ok = True try: import pyloudnorm as pyln lufs = float(pyln.Meter(sr).integrated_loudness(dec.astype(np.float64))) report['lufs'] = round(lufs, 2) if np.isfinite(lufs) else None if a.lufs is not None: lufs_ok = np.isfinite(lufs) and abs(lufs - a.lufs) <= a.lufs_tol say(f'loudness {lufs:.2f} LUFS ' + ('OK' if lufs_ok else f'FAIL (target {a.lufs} +-{a.lufs_tol})')) else: say(f'loudness {lufs:.2f} LUFS') except ImportError: report['lufs'] = None done(sync_ok and tp_ok and lufs_ok) if __name__ == '__main__': main()skills/cinetic/scripts/deliver.sh19 KB7091b69533f9upstream ↗
#!/usr/bin/env bash # deliver.sh: turn a finished master into its deliverables, each checked. # # Usage: # bash scripts/deliver.sh <master.mp4> [options] # # bash scripts/deliver.sh out/film.mp4 # poster only # bash scripts/deliver.sh out/loop.mp4 --loop --gif --webm # landing-page loop set # bash scripts/deliver.sh out/sting.mp4 --alpha StingAlpha,StingAlphaClear # + ProRes 4444 with alpha # bash scripts/deliver.sh out/film.mp4 --variants Film9x16,Film1x1 # re-laid-out social cuts # # What it writes (into --out-dir, default out/deliver/). The top level holds only deliverables, the # folder a client opens; every report goes in its qa/ subfolder: # poster.png / poster.jpg the final frame (the final frame is the poster), or --poster-frame N, # taken from a clean source, not decoded from the lossy master (an H.264 # frame bakes 4:2:0 chroma and block smear into the still): --poster-from # PNG, else a Remotion still of --comp (npx remotion still, same frame), # else the lossless last frame hf-finish.sh keeps (qa/<name>-last-frame.png # next to the master); the source must match the master's frame. Only if # none does is the master's frame used, with a warning # <name>-loop.mp4 --loop: muted, faststart copy, plus a loop-seam check: the step from # the last frame back to frame 0 must look like the steps around it # (seam MAD <= max(0.4, 1.5x the median of the 8 steps at each end); # the report is qa/loop-seam.json) # <name>.gif --gif: two-pass palettegen/paletteuse (25 fps, 960 px wide by default) # <name>.webm --webm: VP9 CRF 32, or 26 with --loop so the seam survives (Opus audio unless --loop), BT.709 tagged # <Comp>.mov --alpha Comp[,Comp2]: Remotion ProRes 4444, yuva444p10le, PNG frames, BT.709 # (a sting ships two: one that ends on the lockup, one that clears) # <Comp>-alpha.webm --alpha-webm: VP9 with alpha (yuva420p) of the same composition # <Comp>.mp4 --variants A,B: each composition through render.sh (sharp master, or # blurred with --variant-samples) # qa/manifest.json every file with its probe facts and check results # # Options: # --out-dir DIR output folder (default out/deliver) # --poster-frame N poster from frame N instead of the last frame # --poster-from PNG the poster's clean source, a lossless still of that frame (e.g. a kept # HyperFrames frame) # --comp ID the composition the master was rendered from, for the poster still # (default Film) # --gif-fps N GIF frame rate (default 25; GIF delays are whole 1/100 s, so 25 or 50 play # at the true rate while 30 plays 11% fast) # --gif-width N GIF width in px (default 960) # --gif-max-mb N warn above this size (default 8) # --gif-dither D paletteuse dither: sierra2_4a (default), bayer, floyd_steinberg, none # --webm-crf N VP9 CRF (default 32; 26 with --loop, because CRF 32 smears the seam past its gate) # --alpha-frames A-B render only this range of the alpha composition (tests) # --alpha-props JSON --props for the alpha render, e.g. '{"transparent":true}' # --entry FILE Remotion entry for --alpha/--variants (default src/index.ts) # --audio FILE soundtrack for --variants (default public/audio/soundtrack.wav) # --variant-samples F blur the variants with the master's samples JSON (render.sh --blur # --samples-from F): only when their timing is identical to the master's. # Without it, variants render sharp, which is right when their fastest # motion is <= 12 px/f. # Exit 0 = everything written and every check passed, 1 = a step or check failed. set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) usage() { sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit "${1:-0}"; } die() { echo "deliver.sh: $*" >&2; exit 1; } step() { echo "[deliver] $*" >&2; } OUTDIR=out/deliver; POSTER_FRAME=""; LOOP=0; GIF=0; WEBM=0; ALPHA=""; ALPHA_WEBM=0; VARIANTS="" GIF_FPS=25; GIF_W=960; GIF_MAX=8; GIF_DITHER=sierra2_4a; WEBM_CRF=""; ALPHA_FRAMES=""; ALPHA_PROPS="" ENTRY=src/index.ts; AUDIO=public/audio/soundtrack.wav; VSAMPLES=""; POS=(); POSTER_FROM=""; COMP=Film while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage 0 ;; --out-dir) OUTDIR=${2:?}; shift ;; --poster-frame) POSTER_FRAME=${2:?}; shift ;; --poster-from) POSTER_FROM=${2:?}; shift ;; --comp) COMP=${2:?}; shift ;; --loop) LOOP=1 ;; --gif) GIF=1 ;; --webm) WEBM=1 ;; --alpha) ALPHA=${2:?}; shift ;; --alpha-webm) ALPHA_WEBM=1 ;; --alpha-frames) ALPHA_FRAMES=${2:?}; shift ;; --alpha-props) ALPHA_PROPS=${2:?}; shift ;; --variants) VARIANTS=${2:?}; shift ;; --gif-fps) GIF_FPS=${2:?}; shift ;; --gif-width) GIF_W=${2:?}; shift ;; --gif-max-mb) GIF_MAX=${2:?}; shift ;; --gif-dither) GIF_DITHER=${2:?}; shift ;; --webm-crf) WEBM_CRF=${2:?}; shift ;; --entry) ENTRY=${2:?}; shift ;; --audio) AUDIO=${2:?}; shift ;; --variant-samples) VSAMPLES=${2:?}; shift ;; -*) die "unknown option $1 (see --help)" ;; *) POS+=("$1") ;; esac shift done [[ ${#POS[@]} -eq 1 ]] || usage 1 MASTER=${POS[0]} [[ -f "$MASTER" ]] || die "master $MASTER not found" command -v ffmpeg >/dev/null || die "ffmpeg not found" mkdir -p "$OUTDIR/qa" QA="$OUTDIR/qa" NAME=$(basename "${MASTER%.*}") STATUS=0 CHECKS="$QA/.checks.jsonl"; : > "$CHECKS" note() { printf '%s\n' "$1" >> "$CHECKS"; } # Python helpers whose output is captured are shell functions called inside $( ), because bash 3.2 # (macOS) cannot parse a heredoc written inside $( ). probe_master() { # probe_master FILE: width height fps frames has_audio python3 - "$1" <<'PY' import json, subprocess, sys from fractions import Fraction d = json.loads(subprocess.run(['ffprobe', '-v', 'error', '-count_packets', '-show_streams', '-of', 'json', sys.argv[1]], capture_output=True, text=True, check=True).stdout) v = next(s for s in d['streams'] if s['codec_type'] == 'video') a = any(s['codec_type'] == 'audio' for s in d['streams']) print(v['width'], v['height'], float(Fraction(v['avg_frame_rate'])), v['nb_read_packets'], int(a)) PY } read -r W H FPS N HAS_AUDIO <<< "$(probe_master "$MASTER")" step "$MASTER: ${W}x${H}@${FPS}, $N frames, audio $([[ $HAS_AUDIO -eq 1 ]] && echo yes || echo no)" TO_RGB="scale=in_color_matrix=auto:in_range=auto:flags=accurate_rnd+full_chroma_int,format=rgb24" # poster: the master's own frame first (the reference, and the fallback), then a clean source if [[ -n "$POSTER_FRAME" ]]; then [[ "$POSTER_FRAME" =~ ^[0-9]+$ && "$POSTER_FRAME" -lt "$N" ]] || die "--poster-frame $POSTER_FRAME outside 0-$((N - 1))" ffmpeg -v error -y -i "$MASTER" -vf "select=eq(n\,$POSTER_FRAME),$TO_RGB" -vsync 0 -frames:v 1 "$QA/.poster-master.png" else # the last frame (decode only the last second) ffmpeg -v error -y -sseof -1 -i "$MASTER" -vf "$TO_RGB" -update 1 "$QA/.poster-master.png" fi [[ -s "$QA/.poster-master.png" ]] || die "poster extraction failed" PF=${POSTER_FRAME:-$((N - 1))} CLEAN="$QA/.poster-clean.png"; rm -f "$CLEAN"; PSRC=""; WHY=() HF_LAST="$(dirname "$MASTER")/qa/$NAME-last-frame.png" if [[ -n "$POSTER_FROM" ]]; then [[ -f "$POSTER_FROM" ]] || die "--poster-from $POSTER_FROM not found" ffmpeg -v error -y -i "$POSTER_FROM" -vf format=rgb24 -frames:v 1 "$CLEAN" && PSRC="$POSTER_FROM" elif [[ -f "$ENTRY" ]] && command -v npx >/dev/null; then PW=$(mktemp -d "${TMPDIR:-/tmp}/cinetic-poster.XXXXXX") if npx remotion bundle "$ENTRY" --out-dir "$PW/bundle" --log=error >/dev/null 2>&1; then CM=$(npx remotion compositions "$PW/bundle" 2>/dev/null \ | awk -v id="$COMP" '$1 == id && $2 ~ /^[0-9.]+$/ && $3 ~ /^[0-9]+x[0-9]+$/ { print $3, $4; exit }' || true) if [[ "$CM" != "${W}x${H} $N" ]]; then WHY+=("composition $COMP is ${CM:-not in $ENTRY}, the master ${W}x${H} $N frames (pass --comp)") elif npx remotion still "$PW/bundle" "$COMP" "$CLEAN" --frame="$PF" --image-format=png --log=error >/dev/null 2>&1; then PSRC="npx remotion still $COMP --frame=$PF" else WHY+=("npx remotion still $COMP failed") fi else WHY+=("could not bundle $ENTRY") fi rm -rf "$PW" elif [[ -f "$HF_LAST" && "$PF" -eq $((N - 1)) ]]; then ffmpeg -v error -y -i "$HF_LAST" -vf format=rgb24 -frames:v 1 "$CLEAN" && PSRC="$HF_LAST" elif [[ -f "$HF_LAST" ]]; then WHY+=("$HF_LAST holds the last frame, not frame $PF; pass --poster-from PNG") else WHY+=("no $ENTRY (Remotion) and no $HF_LAST (hf-finish.sh); pass --poster-from PNG") fi poster_match() { # poster_match STILL MASTER_FRAME: prints the difference, exit 1 if they differ python3 - "$1" "$2" <<'PY' import subprocess, sys import numpy as np def rgb(p): r = subprocess.run(['ffprobe', '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', p], capture_output=True, text=True, check=True).stdout.split(',') w, h = int(r[0]), int(r[1]) b = subprocess.run(['ffmpeg', '-v', 'error', '-i', p, '-vf', 'format=rgb24', '-f', 'rawvideo', '-'], capture_output=True, check=True).stdout return np.frombuffer(b, np.uint8).reshape(h, w, 3).astype(np.int16) a, b = rgb(sys.argv[1]), rgb(sys.argv[2]) if a.shape != b.shape: print(f'it is {a.shape[1]}x{a.shape[0]}, the master {b.shape[1]}x{b.shape[0]}'); sys.exit(1) d = np.abs(a - b) mad, far = float(d.mean()), float((d.max(axis=2) > 32).mean()) # the same frame measures MAD < 1 and < 0.1% of pixels off by > 32 (4:2:0 edges, blur); a neighbouring frame 0.4% print(f'MAD {mad:.2f}, {100 * far:.2f}% of pixels off by > 32 levels') sys.exit(0 if mad <= 2.0 and far <= 0.0025 else 1) PY } if [[ -n "$PSRC" ]]; then # the clean still must show the master's frame: same size, same picture if ! MATCH=$(poster_match "$CLEAN" "$QA/.poster-master.png"); then WHY+=("$PSRC does not match the master's frame $PF ($MATCH): a stale render, the wrong --comp or frame") PSRC="" fi fi if [[ -n "$PSRC" ]]; then mv -f "$CLEAN" "$OUTDIR/poster.png"; rm -f "$QA/.poster-master.png" step "poster.png / poster.jpg (frame $PF, from $PSRC; $MATCH against the master)" note '{"file":"poster.png","check":"poster_source","pass":true,"source":"clean","frame":'"$PF"'}' else mv -f "$QA/.poster-master.png" "$OUTDIR/poster.png"; rm -f "$CLEAN" echo "deliver.sh: warning: poster.png is frame $PF decoded from the lossy master, so it carries 4:2:0 chroma and any" \ "block smear on flat fields: ${WHY[*]:-no clean source}" >&2 step "poster.png / poster.jpg (frame $PF, from the master)" note '{"file":"poster.png","check":"poster_source","pass":true,"source":"master","frame":'"$PF"'}' fi ffmpeg -v error -y -i "$OUTDIR/poster.png" -q:v 2 "$OUTDIR/poster.jpg" if [[ $LOOP -eq 1 ]]; then ffmpeg -v error -y -i "$MASTER" -map 0:v:0 -c copy -an -movflags +faststart "$OUTDIR/$NAME-loop.mp4" if python3 - "$MASTER" "$QA/loop-seam.json" <<'PY' >&2 import json, subprocess, sys import numpy as np src, out = sys.argv[1:3] W, H = 480, 270 raw = subprocess.run(['ffmpeg', '-v', 'error', '-i', src, '-vf', f'scale={W}:{H}:flags=area,format=rgb24', '-f', 'rawvideo', '-'], capture_output=True, check=True).stdout fr = np.frombuffer(raw, np.uint8).reshape(-1, H, W, 3).astype(np.int16) d = np.abs(np.diff(fr, axis=0)).mean(axis=(1, 2, 3)) # step i -> i+1 seam = float(np.abs(fr[0] - fr[-1]).mean()) # last frame -> frame 0 ends = np.concatenate([d[:8], d[-8:]]) # the steps around the wrap local = float(np.median(ends)) if len(ends) else 0.0 limit = max(0.4, 1.5 * local) # same rule as forensics.py --loop ok = seam <= limit res = {'check': 'loop_seam', 'pass': bool(ok), 'seam_mad': round(seam, 3), 'median_step_at_ends': round(local, 3), 'limit': round(limit, 3), 'median_step': round(float(np.median(d)), 3) if len(d) else None, 'rule': 'seam <= max(0.4, 1.5 x median of the 8 steps at each end)'} json.dump(res, open(out, 'w'), indent=1) print(f" loop seam MAD {seam:.3f}, limit {limit:.3f} (median step at the ends {local:.3f}): " + ('OK' if ok else 'FAIL: the last frame does not flow into frame 0 (match state and velocity; whole bars)')) sys.exit(0 if ok else 1) PY then note '{"file":"'"$NAME-loop.mp4"'","check":"loop_seam","pass":true}' else note '{"file":"'"$NAME-loop.mp4"'","check":"loop_seam","pass":false}'; STATUS=1; fi step "$NAME-loop.mp4 (muted)" fi if [[ $GIF -eq 1 ]]; then F="scale=${GIF_W}:-2:flags=lanczos:in_color_matrix=auto:in_range=auto,format=rgb24,fps=${GIF_FPS}" ffmpeg -v error -y -i "$MASTER" -vf "$F,palettegen=max_colors=256:stats_mode=full" "$QA/.palette.png" ffmpeg -v error -y -i "$MASTER" -i "$QA/.palette.png" -lavfi "$F[x];[x][1:v]paletteuse=dither=${GIF_DITHER}:diff_mode=rectangle" \ -loop 0 "$OUTDIR/$NAME.gif" rm -f "$QA/.palette.png" mb=$(python3 -c "import os;print(round(os.path.getsize('$OUTDIR/$NAME.gif')/1e6,2))") if python3 -c "import sys; sys.exit(0 if $mb <= $GIF_MAX else 1)"; then note '{"file":"'"$NAME.gif"'","check":"size","pass":true,"mb":'"$mb"'}' else note '{"file":"'"$NAME.gif"'","check":"size","pass":false,"mb":'"$mb"'}' echo "deliver.sh: warning: $NAME.gif is $mb MB (> $GIF_MAX): lower --gif-width/--gif-fps, shorten the loop or ship the WebM/MP4" >&2 fi step "$NAME.gif (${GIF_W}px, ${GIF_FPS} fps, $mb MB)" fi if [[ $WEBM -eq 1 ]]; then [[ -z "$WEBM_CRF" ]] && { [[ $LOOP -eq 1 ]] && WEBM_CRF=26 || WEBM_CRF=32; } AUD=(-an); [[ $HAS_AUDIO -eq 1 && $LOOP -eq 0 ]] && AUD=(-c:a libopus -b:a 160k) # setparams labels the master's BT.709 frames with all four tags (no conversion): ffmpeg 7.1 and # newer tag the stream from the frames, over -color_primaries/-color_trc (finishing.md, section 4) ffmpeg -v error -y -i "$MASTER" -map 0:v:0 $([[ $HAS_AUDIO -eq 1 && $LOOP -eq 0 ]] && echo "-map 0:a:0") \ -vf setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv \ -c:v libvpx-vp9 -crf "$WEBM_CRF" -b:v 0 -row-mt 1 -deadline good -cpu-used 2 -pix_fmt yuv420p \ -colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv ${AUD[@]+"${AUD[@]}"} "$OUTDIR/$NAME.webm" \ || die "webm encode failed" step "$NAME.webm (VP9 CRF $WEBM_CRF$([[ ${AUD[0]} == -an ]] && echo ', muted'))" fi if [[ -n "$ALPHA" ]]; then IFS=',' read -r -a ALPHAS <<< "$ALPHA" for ALPHA in ${ALPHAS[@]+"${ALPHAS[@]}"}; do command -v npx >/dev/null || die "--alpha needs npx/Remotion" [[ -f "$ENTRY" ]] || die "--alpha needs the Remotion entry $ENTRY (run from the project root)" RA=(--muted --log=error --image-format=png --color-space=bt709) [[ -n "$ALPHA_FRAMES" ]] && RA+=(--frames="$ALPHA_FRAMES") [[ -n "$ALPHA_PROPS" ]] && RA+=(--props="$ALPHA_PROPS") step "rendering $ALPHA as ProRes 4444 with alpha" npx remotion render "$ENTRY" "$ALPHA" "$OUTDIR/$ALPHA.mov" --codec=prores --prores-profile=4444 \ --pixel-format=yuva444p10le ${RA[@]+"${RA[@]}"} || die "alpha render failed" if [[ $ALPHA_WEBM -eq 1 ]]; then npx remotion render "$ENTRY" "$ALPHA" "$OUTDIR/$ALPHA-alpha.webm" --codec=vp9 --pixel-format=yuva420p ${RA[@]+"${RA[@]}"} \ || die "VP9 alpha render failed" fi if python3 - "$OUTDIR/$ALPHA.mov" <<'PY' >&2 import json, subprocess, sys import numpy as np f = sys.argv[1] s = json.loads(subprocess.run(['ffprobe', '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_name,pix_fmt,profile', '-of', 'json', f], capture_output=True, text=True).stdout)['streams'][0] raw = subprocess.run(['ffmpeg', '-v', 'error', '-i', f, '-vf', 'scale=320:-2', '-frames:v', '1', '-f', 'rawvideo', '-pix_fmt', 'rgba', '-'], capture_output=True).stdout a = np.frombuffer(raw, np.uint8)[3::4] clear = float((a < 250).mean()) if len(a) else 0.0 # Remotion encodes yuva444p10le; ffprobe reports the ProRes decoder's format, yuva444p12le ok = s.get('codec_name') == 'prores' and str(s.get('pix_fmt', '')).startswith('yuva444p') print(f" {f}: {s.get('codec_name')} {s.get('profile')} {s.get('pix_fmt')}, first frame {clear:.0%} non-opaque" + ('' if clear > 0 else ' (warning: fully opaque; paint no background when rendering the alpha version)')) sys.exit(0 if ok else 1) PY then note '{"file":"'"$ALPHA.mov"'","check":"prores_alpha","pass":true}' else note '{"file":"'"$ALPHA.mov"'","check":"prores_alpha","pass":false}'; STATUS=1; fi done fi if [[ -n "$VARIANTS" ]]; then IFS=',' read -r -a VS <<< "$VARIANTS" for V in ${VS[@]+"${VS[@]}"}; do step "rendering variant $V" VA=(); [[ $HAS_AUDIO -eq 1 ]] && VA=(--audio "$AUDIO") || VA=(--no-audio) [[ -n "$VSAMPLES" ]] && VA+=(--blur --samples-from "$VSAMPLES" --samples "$QA/samples-$V.json") if bash "$SCRIPT_DIR/render.sh" "$V" "$OUTDIR/$V.mp4" ${VA[@]+"${VA[@]}"} --entry "$ENTRY"; then note '{"file":"'"$V.mp4"'","check":"render","pass":true}' else note '{"file":"'"$V.mp4"'","check":"render","pass":false}'; STATUS=1 fi done fi python3 - "$OUTDIR" "$CHECKS" "$MASTER" <<'PY' import json, os, subprocess, sys from fractions import Fraction outdir, checks, master = sys.argv[1:4] items = [] for name in sorted(os.listdir(outdir)): p = os.path.join(outdir, name) if name.startswith('.') or not os.path.isfile(p): continue # the qa/ folder and hidden files are not deliverables e = {'file': name, 'mb': round(os.path.getsize(p) / 1e6, 3)} r = subprocess.run(['ffprobe', '-v', 'error', '-show_streams', '-show_format', '-of', 'json', p], capture_output=True, text=True) if r.returncode == 0: d = json.loads(r.stdout) v = next((s for s in d.get('streams', []) if s['codec_type'] == 'video'), None) if v: e.update(codec=v['codec_name'], size=f"{v['width']}x{v['height']}", pix_fmt=v.get('pix_fmt')) try: fr = float(Fraction(v.get('avg_frame_rate', '0/1'))) if fr > 0 and d['format'].get('duration'): e.update(fps=round(fr, 3), seconds=round(float(d['format']['duration']), 3)) except (ValueError, ZeroDivisionError): pass e['audio'] = any(s['codec_type'] == 'audio' for s in d['streams']) items.append(e) res = [json.loads(l) for l in open(checks) if l.strip()] m = {'master': master, 'files': items, 'checks': res, 'pass': all(c.get('pass', True) for c in res)} json.dump(m, open(os.path.join(outdir, 'qa', 'manifest.json'), 'w'), indent=1) for e in items: print(f" {e['file']:<28} {e['mb']:>8.2f} MB {e.get('codec', '')} {e.get('size', '')} {e.get('pix_fmt', '') or ''}", file=sys.stderr) PY rm -f "$CHECKS" stray=$(find "$OUTDIR" -maxdepth 1 -type f \( -name '*.json' -o -name 'sheet*' -o -name 'qa-*' -o -name '*.log' \) | head -3 | tr '\n' ' ') [[ -z "$stray" ]] || echo "deliver.sh: warning: QA files at the top of $OUTDIR ($stray); move them into $QA, the top level is for deliverables" >&2 step "$([[ $STATUS -eq 0 ]] && echo 'done' || echo 'FAILED a check'): $QA/manifest.json" exit $STATUSskills/cinetic/scripts/dither-gradient.py11 KB737fb1666c24upstream ↗
#!/usr/bin/env python3 """Bake soft backdrops (pools of light, vignettes, tabletop falloffs, floors) as dithered PNGs from a JSON spec. Chromium renders CSS gradients in 8 bits. A ramp that spans only a few code values (a pool of light on ink going 11 -> 29, a light table going 231 -> 247) shows as contour rings, and nothing downstream (motion blur, encoder, grain added later) can remove bands that were quantized in the browser. This script evaluates the same gradient in float, adds a +-1 LSB triangular (TPDF) dither and quantizes once. Load the PNG with <Img src={staticFile('fx/NAME.png')}> (Remotion) or <img> in HyperFrames, full-frame, instead of the CSS gradient. Usage: python3 scripts/dither-gradient.py --spec backdrops.json --out public/fx [--preview out/qa/bands] python3 scripts/dither-gradient.py --example > backdrops.json # print a starter spec Spec (positions are fractions; colours are #RGB, #RRGGBB, #RRGGBBAA, rgb(), rgba() or 'transparent'): {"size": [1920, 1080], "seed": 11, "backdrops": [ {"name": "pool-ink", "type": "radial", "at": [0.5, 0.45], "radius": [0.7, 0.6], "stops": [["rgba(255,255,255,0.075)", 0], ["transparent", 0.7]]}, {"name": "table", "base": "#E7E8EC", "type": "radial", "at": [0.5, 0.4], "radius": [0.65, 0.6], "stops": [["#F7F8FA", 0], ["#F7F8FA00", 0.75]]}, {"name": "floor", "type": "linear", "angle": 180, "space": "oklab", "stops": [["#0B0C0E", 0], ["#16181C", 1]]}, {"name": "stage", "base": "#0B0C0E", "layers": [ {...gradient...}, {...gradient...} ]}]} type radial: CSS 'radial-gradient(ellipse RX RY at CX CY, ...)' with RX/RY as fractions of width/ height (or "radius": R px for a circle); stop positions along the ray (1 = ellipse edge). type linear: CSS 'linear-gradient(ANGLEdeg, ...)' (180 = top to bottom), stop positions along the CSS gradient line. space: srgb (CSS default, premultiplied), linear (linear light) or oklab (smoothest ramps). base: opaque colour under the layers -> RGB PNG; without base -> RGBA PNG (alpha dithered). Options: --dither LSB (1.0; 0 = off to see the bands), --size WxH (override), --preview DIR (writes a x12 contrast-stretched copy per backdrop so bands would be obvious), --quiet. Prints a JSON report (files, code-value span per backdrop, banding risk as a CSS gradient). Exit 0 on success, 1 on a spec error. """ import argparse import json import os import re import sys import numpy as np from PIL import Image EXAMPLE = { 'size': [1920, 1080], 'seed': 11, 'backdrops': [ {'name': 'pool-ink', 'type': 'radial', 'at': [0.5, 0.45], 'radius': [0.7, 0.6], 'stops': [['rgba(255,255,255,0.075)', 0], ['transparent', 0.7]]}, {'name': 'table', 'base': '#E7E8EC', 'type': 'radial', 'at': [0.5, 0.4], 'radius': [0.65, 0.6], 'stops': [['#F7F8FA', 0], ['#F7F8FA00', 0.75]]}, {'name': 'floor', 'type': 'linear', 'angle': 180, 'space': 'oklab', 'stops': [['#0B0C0E', 0], ['#16181C', 1]]}, ], } def fail(msg): sys.stderr.write(f'dither-gradient.py: {msg}\n') sys.exit(1) def parse_color(c): """-> (r, g, b, a) floats, rgb in 0..1 gamma-encoded sRGB.""" s = str(c).strip().lower() if s == 'transparent': return (0.0, 0.0, 0.0, 0.0) m = re.fullmatch(r'#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})', s) if m: h = m.group(1) if len(h) in (3, 4): h = ''.join(ch * 2 for ch in h) v = [int(h[i:i + 2], 16) / 255 for i in range(0, len(h), 2)] return tuple(v[:3]) + ((v[3],) if len(v) == 4 else (1.0,)) m = re.fullmatch(r'rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+%?)\s*)?\)', s) if m: al = m.group(4) alpha = 1.0 if al is None else (float(al[:-1]) / 100 if al.endswith('%') else float(al)) return (float(m.group(1)) / 255, float(m.group(2)) / 255, float(m.group(3)) / 255, alpha) fail(f'unknown colour {c!r}') def to_linear(c): return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) def from_linear(c): c = np.clip(c, 0, 1) return np.where(c <= 0.0031308, c * 12.92, 1.055 * c ** (1 / 2.4) - 0.055) M1 = np.array([[0.4122214708, 0.5363325363, 0.0514459929], [0.2119034982, 0.6806995451, 0.1073969566], [0.0883024619, 0.2817188376, 0.6299787005]]) M2 = np.array([[0.2104542553, 0.7936177850, -0.0040720468], [1.9779984951, -2.4285922050, 0.4505937099], [0.0259040371, 0.7827717662, -0.8086757660]]) def to_space(rgb, space): if space == 'srgb': return rgb lin = to_linear(rgb) if space == 'linear': return lin return np.cbrt(lin @ M1.T) @ M2.T # oklab def from_space(v, space): if space == 'srgb': return np.clip(v, 0, 1) if space == 'oklab': v = (v @ np.linalg.inv(M2).T) ** 3 @ np.linalg.inv(M1).T return from_linear(v) def gradient_t(layer, W, H, x, y): kind = layer.get('type', 'radial') if kind == 'radial': cx, cy = layer.get('at', [0.5, 0.5]) r = layer.get('radius', [0.5, 0.5]) if isinstance(r, (int, float)): return np.hypot(x - cx * W, y - cy * H) / float(r) return np.hypot((x - cx * W) / (r[0] * W), (y - cy * H) / (r[1] * H)) if kind == 'linear': th = np.radians(float(layer.get('angle', 180))) dx, dy = np.sin(th), -np.cos(th) length = abs(W * dx) + abs(H * dy) return ((x - W / 2) * dx + (y - H / 2) * dy) / length + 0.5 fail(f"unknown gradient type {kind!r} (radial or linear)") def render_layer(layer, W, H, x, y): """-> premultiplied (H, W, 3) colour in gamma sRGB and (H, W) alpha.""" stops = layer.get('stops') if not stops or len(stops) < 2: fail(f"layer needs >= 2 stops: {layer}") space = layer.get('space', 'srgb') if space not in ('srgb', 'linear', 'oklab'): fail(f'unknown space {space!r}') cols = [parse_color(s[0]) for s in stops] pos = [float(s[1]) if len(s) > 1 else i / (len(stops) - 1) for i, s in enumerate(stops)] if any(b < a for a, b in zip(pos, pos[1:])): fail(f'stop positions must not decrease: {pos}') t = gradient_t(layer, W, H, x, y) rgb = np.array([c[:3] for c in cols], dtype=np.float64) al = np.array([c[3] for c in cols], dtype=np.float64) # CSS: a fully transparent stop takes its neighbour's colour (premultiplied interpolation) for i in range(len(cols)): if al[i] == 0: nb = [j for j in (i - 1, i + 1) if 0 <= j < len(cols) and al[j] > 0] if nb: rgb[i] = rgb[nb[0]] comp = to_space(rgb, space) * al[:, None] # premultiplied in the interpolation space tc = np.clip(t, pos[0], pos[-1]) out = np.empty(t.shape + (3,)) for ch in range(3): out[..., ch] = np.interp(tc, pos, comp[:, ch]) a = np.interp(tc, pos, al) safe = np.where(a > 1e-9, a, 1.0)[..., None] straight = from_space(out / safe, space) return straight * a[..., None], a def build(bd, W, H, rng, dither): y, x = np.mgrid[0:H, 0:W].astype(np.float64) + 0.5 layers = bd.get('layers') or [bd] base = bd.get('base') if base is not None: b = parse_color(base) col = np.broadcast_to(np.array(b[:3]), (H, W, 3)).copy() alpha = np.ones((H, W)) else: col = np.zeros((H, W, 3)) alpha = np.zeros((H, W)) for layer in layers: c, a = render_layer(layer, W, H, x, y) col = c + col * (1 - a[..., None]) # source-over, premultiplied alpha = a + alpha * (1 - a) tpdf = (rng.random((H, W)) + rng.random((H, W)) - 1.0) * dither if base is not None or alpha.min() >= 1 - 1e-9: # opaque result -> RGB PNG v = col * 255 + tpdf[..., None] img = np.clip(np.round(v), 0, 255).astype(np.uint8) return Image.fromarray(img, 'RGB'), img, col * 255 safe = np.where(alpha > 1e-9, alpha, 1.0)[..., None] straight = col / safe * 255 clear = alpha <= 1e-9 if clear.any() and (~clear).any(): # fully clear pixels keep the ramp's colour: no dark fringe when scaled straight[clear] = straight[~clear].mean(axis=0) varies = (straight.max(axis=(0, 1)) - straight.min(axis=(0, 1))).max() >= 0.5 tpdf2 = (rng.random((H, W)) + rng.random((H, W)) - 1.0) * dither if varies else np.zeros((H, W)) rgb = np.clip(np.round(straight + tpdf2[..., None]), 0, 255).astype(np.uint8) a8 = np.clip(np.round(alpha * 255 + tpdf), 0, 255).astype(np.uint8) img = np.dstack([rgb, a8]) return Image.fromarray(img, 'RGBA'), img, np.dstack([straight, alpha * 255]) def main(): ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0], formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('--spec', help='backdrops JSON spec') ap.add_argument('--out', default='public/fx', help='output directory (default public/fx)') ap.add_argument('--size', help='WxH, overrides the spec size') ap.add_argument('--dither', type=float, default=1.0, help='TPDF amplitude in LSB (0 = off)') ap.add_argument('--preview', help='directory for x12 contrast-stretched previews') ap.add_argument('--example', action='store_true', help='print an example spec and exit') ap.add_argument('--quiet', action='store_true') a = ap.parse_args() if a.example: print(json.dumps(EXAMPLE, indent=1)) return if not a.spec: ap.error('--spec is required (or --example)') try: spec = json.load(open(a.spec)) except (OSError, ValueError) as e: fail(f'cannot read spec {a.spec}: {e}') W, H = spec.get('size', [1920, 1080]) if a.size: W, H = (int(v) for v in a.size.lower().split('x')) rng = np.random.default_rng(int(spec.get('seed', 11))) os.makedirs(a.out, exist_ok=True) if a.preview: os.makedirs(a.preview, exist_ok=True) report = {'out': a.out, 'size': [W, H], 'backdrops': []} for bd in spec.get('backdrops', []): name = bd.get('name') if not name or not re.fullmatch(r'[\w.-]+', name): fail(f'backdrop needs a file-safe "name": {bd}') im, arr, exact = build(bd, W, H, rng, a.dither) path = os.path.join(a.out, f'{name}.png') im.save(path, compress_level=6) ch = exact.reshape(-1, exact.shape[-1]) ch = ch[:, 3:] if im.mode == 'RGBA' else ch # a translucent layer's ramp lives in its alpha span = float((ch.max(0) - ch.min(0)).max()) entry = {'name': name, 'file': path, 'mode': im.mode, 'code_span': round(span, 1), 'css_banding_risk': bool(0 < span < 30), 'mean': [round(float(v), 2) for v in arr.reshape(-1, arr.shape[-1]).mean(0)]} if a.preview: if im.mode == 'RGBA': # composite over mid grey so the alpha ramp is visible rgb = arr[..., :3].astype(np.float64) * (arr[..., 3:] / 255) + 128 * (1 - arr[..., 3:] / 255) else: rgb = arr.astype(np.float64) m = rgb.mean(axis=(0, 1)) st = np.clip((rgb - m) * 12 + 128, 0, 255).astype(np.uint8) pp = os.path.join(a.preview, f'{name}.stretch.png') Image.fromarray(st).resize((W // 2, H // 2), Image.BILINEAR).save(pp) entry['preview'] = pp report['backdrops'].append(entry) if not a.quiet: sys.stderr.write(f" {path}: {im.mode}, span {span:.1f} code values" + (' (would band as a CSS gradient: use this PNG)' if entry['css_banding_risk'] else '') + '\n') if not report['backdrops']: fail('spec has no backdrops') print(json.dumps(report, indent=1)) if __name__ == '__main__': main()skills/cinetic/scripts/export-cues.ts11 KB7a11a4fbea87upstream ↗
/** * export-cues.ts: writes out/cues.json, the contract between the picture and the soundtrack. * * It imports the project's timeline (FPS, BPM, TOTAL, ACT, CUE, COPY, PACE_NAME, READ) and the default * export of src/sync.ts: a function cues() that returns sound events computed from the same * constants, springs and easings the acts animate with. Sync points are computed, never typed. * * Usage (from the project root): * npx tsx scripts/export-cues.ts [--sync src/sync.ts] [--timeline src/timeline.ts] * [--out out/cues.json] [--allow-unknown] [--quiet] * * Output schema: * { fps, bpm, total, pace?, acts: {name: {from, dur}}, cue: {name: frame}, * copy: [{ id, text, in, resolved, out, words, kind, chain?, mode?, read: [[from, to], ...] }], * events: [{ f, kind, weight, pan, pitch?, apexFrac?, dur?, variant?, id? }] } * f is the absolute frame the sound belongs on (fractional allowed), weight 0..2 (1 = normal), * pan -1..1, pitch a MIDI number or a note name such as "Eb6", apexFrac (whoosh, suck) the point * of the sound that lands on f, dur a length in frames. See references/sound.md. Kind `text` is * the one soft sound of a copy fragment landing (fx/Chain chainResolves()), never one per word. * `copy` is the timeline's COPY (every on-screen string with its frames). Each entry's `read` lists * the frames in which it is being read, half-open [from, to) ranges: the timeline's READ * (src/lib/pace.ts readingWindows(): from `resolved` for at most the pace's readMax, an earlier * fragment of a chain only for its own hold, a build line once more after its last fragment), or * the same rule computed here for a timeline that does not export READ. forensics.py, * measure-speed.py and grid-check --cues read these windows, and `pace` (the timeline's * PACE_NAME) sets their calm or crisp thresholds. * * Exit codes: 0 written, 1 import failure or invalid events (nothing is written). */ import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { dirname, resolve, relative } from 'node:path'; import { pathToFileURL } from 'node:url'; export const KINDS = [ 'tick', 'tock', 'hit', 'land', 'pop', 'whoosh', 'riser', 'drop', 'key', 'click', 'snap', 'swell', 'bell', 'suck', 'text', ] as const; export type Kind = (typeof KINDS)[number]; export type SoundEvent = { f: number; kind: Kind | string; weight?: number; pan?: number; pitch?: number | string; apexFrac?: number; dur?: number; variant?: string; id?: string; }; const FIELDS = new Set(['f', 'kind', 'weight', 'pan', 'pitch', 'apexFrac', 'dur', 'variant', 'id']); // The hold and read numbers of src/lib/pace.ts, for a timeline that exports neither READ nor PACE. type ReadPace = { holdBase: number; holdPerWord: number; holdMin: number; readMax: number }; const READ_PACE: Record<string, ReadPace> = { calm: { holdBase: 42, holdPerWord: 8, holdMin: 60, readMax: 150 }, crisp: { holdBase: 36, holdPerWord: 6, holdMin: 42, readMax: 120 }, }; type Windows = Record<string, [number, number][]>; const wordsOf = (t: string) => String(t).split(/\s+/).filter(Boolean).length; /** src/lib/pace.ts readingWindows(), for timelines written before it existed: the same rule. */ const readingWindows = (copy: { id: string; text: string; in: number; resolved: number; out: number; chain?: string; mode?: string }[], P: ReadPace, fps: number): Windows => { const s = fps / 60; const cap = Math.round(P.readMax * s); const hold = (n: number) => Math.round(Math.max(P.holdMin, P.holdBase + P.holdPerWord * n) * s); const w: Windows = {}; for (const c of copy) { const chain = c.chain ? copy.filter((x) => x.chain === c.chain).sort((a, b) => a.in - b.in) : [c]; const last = chain[chain.length - 1]; const next = chain[chain.indexOf(c) + 1]; const own: [number, number] = next ? [c.resolved, Math.min(next.in, c.resolved + hold(wordsOf(c.text)), c.out)] : [c.resolved, Math.min(c.out, c.resolved + cap)]; const line: [number, number] | null = next && (c.mode ?? 'build') === 'build' ? [last.resolved, Math.min(last.out, last.resolved + cap)] : null; w[c.id] = (line ? [own, line] : [own]).filter(([a, b]) => b > a); } return w; }; const isWindows = (v: unknown): v is Windows => !!v && typeof v === 'object' && Object.values(v as object).every((l) => Array.isArray(l) && l.every((r) => Array.isArray(r) && r.length === 2 && r.every(Number.isFinite))); const NOTE = /^[A-Ga-g][#b]{0,2}-?\d$/; const arg = (name: string, fallback?: string) => { const i = process.argv.indexOf(name); return i > 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback; }; const flag = (name: string) => process.argv.includes(name); const fail = (msg: string): never => { console.error(`export-cues: ${msg}`); process.exit(1); }; async function load(path: string) { if (!existsSync(path)) fail(`cannot find ${path}`); try { return await import(pathToFileURL(resolve(path)).href); } catch (e) { return fail(`importing ${path} failed:\n${(e as Error).stack ?? e}`); } } async function main() { if (flag('--help') || flag('-h')) { console.log('npx tsx scripts/export-cues.ts [--sync src/sync.ts] [--timeline src/timeline.ts] [--out out/cues.json] [--allow-unknown] [--quiet]'); return; } const syncPath = arg('--sync', 'src/sync.ts')!; const tlPath = arg('--timeline', resolve(dirname(syncPath), 'timeline.ts'))!; const outPath = arg('--out', 'out/cues.json')!; const quiet = flag('--quiet'); const tl = await load(tlPath); const T = tl.default && tl.FPS === undefined ? tl.default : tl; const { FPS, BPM, TOTAL, ACT, CUE, COPY, PACE_NAME, PACE, READ } = T; for (const [k, v] of Object.entries({ FPS, BPM, TOTAL })) { if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) fail(`${tlPath} must export a positive number ${k}`); } if (!ACT || typeof ACT !== 'object') fail(`${tlPath} must export ACT ({name: {from, dur}})`); if (!CUE || typeof CUE !== 'object') fail(`${tlPath} must export CUE ({name: frame})`); const mod = await load(syncPath); const exp = mod.default?.default ?? mod.default ?? mod.cues; const raw: unknown = typeof exp === 'function' ? await exp() : exp; const list = Array.isArray(raw) ? raw : (raw as { events?: unknown[] })?.events; if (!Array.isArray(list)) fail(`${syncPath}: default export cues() must return an array of events`); const errors: string[] = []; const warnings: string[] = []; const events = (list as SoundEvent[]).map((e, i) => { const where = `event ${i}${e?.id ? ` (${e.id})` : ''}`; if (!e || typeof e !== 'object') { errors.push(`${where}: not an object`); return null; } for (const k of Object.keys(e)) if (!FIELDS.has(k)) warnings.push(`${where}: unknown field "${k}" dropped`); const f = Number(e.f); if (!Number.isFinite(f)) errors.push(`${where}: f must be a frame number, got ${e.f}`); else if (f < 0 || f >= TOTAL) errors.push(`${where}: f=${f} is outside the film (0..${TOTAL - 1})`); else if (f > TOTAL - 30 && ['hit', 'drop', 'swell', 'bell', 'snap'].includes(e.kind)) warnings.push(`${where}: ${e.kind} at f=${f} has under 30 f to ring out before the end`); if (!KINDS.includes(e.kind as Kind)) { if (flag('--allow-unknown')) warnings.push(`${where}: unknown kind "${e.kind}" (score.py will skip it)`); else errors.push(`${where}: unknown kind "${e.kind}"; use one of ${KINDS.join(', ')}`); } const weight = e.weight ?? 1; if (!(Number.isFinite(weight) && weight > 0 && weight <= 2)) errors.push(`${where}: weight must be in (0, 2], got ${e.weight}`); const pan = e.pan ?? 0; if (!(Number.isFinite(pan) && pan >= -1 && pan <= 1)) errors.push(`${where}: pan must be in [-1, 1], got ${e.pan}`); if (e.pitch !== undefined && !(typeof e.pitch === 'number' ? Number.isFinite(e.pitch) : NOTE.test(String(e.pitch)))) errors.push(`${where}: pitch must be a MIDI number or a note like "Eb6", got ${e.pitch}`); if (e.apexFrac !== undefined && !(e.apexFrac > 0 && e.apexFrac < 1)) errors.push(`${where}: apexFrac must be in (0, 1)`); if (e.dur !== undefined && !(Number.isFinite(e.dur) && e.dur > 0)) errors.push(`${where}: dur must be > 0 frames`); const out: SoundEvent = { f: Math.round(f * 1000) / 1000, kind: e.kind, weight: +(+weight).toFixed(3), pan: +(+pan).toFixed(3) }; if (e.pitch !== undefined) out.pitch = e.pitch; if (e.kind === 'whoosh' || e.kind === 'suck' || e.apexFrac !== undefined) out.apexFrac = +(e.apexFrac ?? (e.kind === 'suck' ? 0.85 : 0.5)).toFixed(3); if (e.dur !== undefined) out.dur = e.dur; if (e.variant !== undefined) out.variant = String(e.variant); if (e.id !== undefined) out.id = String(e.id); return out; }); if (errors.length) fail(`${errors.length} invalid event(s):\n ${errors.join('\n ')}`); const sorted = (events as SoundEvent[]).sort((a, b) => a.f - b.f || String(a.kind).localeCompare(String(b.kind))); for (let i = 1; i < sorted.length; i++) { const a = sorted[i - 1]; const b = sorted[i]; if (a.kind === b.kind && a.kind !== 'key' && Math.abs(a.f - b.f) < 0.5) warnings.push(`two ${a.kind} events on frame ${b.f}: one sound per visible event`); } const acts: Record<string, { from: number; dur: number }> = {}; for (const [k, v] of Object.entries(ACT as Record<string, { from: number; dur: number }>)) acts[k] = { from: v.from, dur: v.dur }; const cue: Record<string, number> = {}; for (const [k, v] of Object.entries(CUE as Record<string, unknown>)) if (typeof v === 'number') cue[k] = v; // The copy block: every on-screen string with its frames, so the render checks know when text is being read. type CopyIn = { id: string; text: string; in: number; resolved: number; out?: number | null; kind?: string; chain?: string; mode?: string }; const copy = (Array.isArray(COPY) ? (COPY as CopyIn[]) : []).map((c) => ({ id: String(c.id), text: String(c.text), in: c.in, resolved: c.resolved, out: c.out ?? TOTAL, words: String(c.text).split(/\s+/).filter(Boolean).length, kind: c.kind ?? 'copy', ...(c.chain ? { chain: c.chain, mode: c.mode ?? 'build' } : {}), })); for (const c of copy) if (![c.in, c.resolved, c.out].every((v) => typeof v === 'number' && Number.isFinite(v))) fail(`COPY ${c.id}: in/resolved/out must be frames`); // reading windows: the timeline's own READ (one rule for every check), else the same rule here const paceKey = typeof PACE_NAME === 'string' && PACE_NAME in READ_PACE ? PACE_NAME : 'crisp'; const readPace: ReadPace = { ...READ_PACE[paceKey], ...((PACE as Record<string, Partial<ReadPace>> | undefined)?.[paceKey] ?? {}) }; const windows: Windows = isWindows(READ) ? READ : readingWindows(copy, readPace, FPS); const copyOut = copy.map((c) => ({ ...c, read: windows[c.id] ?? [] })); const doc = { fps: FPS, bpm: BPM, total: TOTAL, ...(typeof PACE_NAME === 'string' ? { pace: PACE_NAME } : {}), acts, cue, copy: copyOut, events: sorted }; mkdirSync(dirname(resolve(outPath)), { recursive: true }); writeFileSync(outPath, JSON.stringify(doc, null, 1) + '\n'); if (!quiet) { for (const w of warnings) console.warn(`warning: ${w}`); const byKind: Record<string, number> = {}; for (const e of sorted) byKind[e.kind] = (byKind[e.kind] ?? 0) + 1; console.log( `wrote ${relative(process.cwd(), resolve(outPath))}: ${sorted.length} events ` + `(${Object.entries(byKind).map(([k, n]) => `${k} ${n}`).join(', ')}), ` + `${Object.keys(cue).length} cues, ${copy.length} copy entries, ${(TOTAL / FPS).toFixed(2)} s at ${FPS} fps, ${BPM} BPM`, ); } } main();skills/cinetic/scripts/forensics.py62 KBa17bbfc9b0ebupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/grab.sh2.6 KB2a44ef563550upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/grid-check.ts31 KBaf43951dcbafupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/hf-finish.sh14 KB5765ec4e8c69upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/hf-preflight.cjs5.2 KBe2cb48ba622cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/layout-audit.sh15 KBf0f0cb2de530upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/lint-film.mjs58 KBaf2826c0f7c2upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/measure-speed.py31 KBde41cadcfdddupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/new-film.sh14 KB30ec350a1fa6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/outline-text.py4.9 KB0f56efa95b36upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/palette.py17 KB493d4e47d15fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/pick.py21 KB4340cabb2fedupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/probe.py14 KB8d4c21410072upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/render-chunks.sh5.8 KB2f24752cd6daupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/render.sh20 KB6f1cdf9bea96upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/sheet.py13 KB67b89dae4e19upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/bash32.test.py9.6 KB7e6b85cdfcb6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/encode-tags.test.py4.8 KBa12d518e3fa6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/grid-check.test.mjs11 KB47d915f880abupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/grid-fixtures/calm-chains.ts1.2 KB080a9d263604upstream ↗
// Fixture for scripts/test/grid-check.test.mjs. expect: (none) // A calm film that passes clean: a build chain (two fragments around a picture beat with a cue in // it), a replace chain whose first fragment has left before the next rises, reads that carry the // event gaps with no cue inside them, text on under 65% of the body and a text-free beat. export const FPS = 60; export const BPM = 100; export const PACE_NAME = 'calm'; const BEAT = 36; const b = (bar: number, beat = 0) => ((bar - 1) * 4 + beat) * BEAT; export const TAIL = 60; export const ACT = { a: { from: 0, dur: b(4) }, b: { from: b(4), dur: b(7) + TAIL - b(4) } }; export const TOTAL = b(7) + TAIL; export const CUE = { a: b(1), beat: b(1, 3), b: b(2), exit: b(3), x1: b(3, 2), c: b(4), turn: b(4, 3), e: b(5), f: b(6), g: b(6, 2), end: b(7) }; export const COPY = [ { id: 's.1', text: 'Built on', in: 0, resolved: 47, out: 288, chain: 's', mode: 'build' }, { id: 's.2', text: 'the beat', in: 144, resolved: 191, out: 288, chain: 's', mode: 'build' }, { id: 'r.1', text: 'One move', in: 432, resolved: 479, out: 546, chain: 'r', mode: 'replace' }, { id: 'r.2', text: 'then the next', in: 576, resolved: 634, out: 720, chain: 'r', mode: 'replace' }, ];skills/cinetic/scripts/test/grid-fixtures/chain-beat.ts788 B00e035ddd972upstream ↗
// Fixture for scripts/test/grid-check.test.mjs. expect: chain-beat=1 // A build chain whose picture beat is empty: 37 f between the first fragment's read (f107) and the // second fragment, short enough to pass the event gap, but no cue there, so nothing in the picture // takes its turn (an error when calm). export const FPS = 60; export const BPM = 100; export const PACE_NAME = 'calm'; export const TAIL = 60; export const ACT = { a: { from: 0, dur: 576 + TAIL } }; export const TOTAL = 576 + TAIL; export const CUE = { a: 0, b: 144, c: 369, d: 441, e: 513, end: 576 }; export const COPY = [ { id: 's.1', text: 'Built on', in: 0, resolved: 47, out: 300, chain: 's', mode: 'build' }, { id: 's.2', text: 'the beat', in: 144, resolved: 191, out: 300, chain: 's', mode: 'build' }, ];skills/cinetic/scripts/test/grid-fixtures/chain-gaps.ts1.4 KB9f57f408aacdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/grid-fixtures/crisp-legacy.ts717 Bdefa7a49c061upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/grid-fixtures/crisp-soft.ts1.1 KBec0726565f56upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/grid-fixtures/lines-together.ts1002 Bc42d72196d1cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/grid-fixtures/read-quiet.ts616 Bb10bf1d1192bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/grid-fixtures/reads-and-gaps.ts603 Ba053a4d8064fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/grid-fixtures/replace-exit.ts866 B413ea3d545faupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/grid-fixtures/sentence-dump.ts789 B7ff198f6b2e6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-film.test.mjs6.7 KBacf1ca517a23upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/act-copy-literal.tsx858 B911508815a2bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/act-sibling-words.tsx752 B444a75a1aaa6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/banned-color.ts1.1 KBc97d99106529upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/banned-named.tsx320 B4ae44e9b5828upstream ↗
// Fixture. expect: banned-color=3 import React from 'react'; export const Named: React.FC = () => ( <svg> <rect fill="orange" width={10} height={10} /> <rect style={{ background: 'lavender', color: 'gold' }} width={10} height={10} /> <rect fill="teal" stroke="white" width={10} height={10} /> </svg> );skills/cinetic/scripts/test/lint-fixtures/brand-file.tsx357 B68280a9314aeupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/brand-line.tsx318 B96bc004ec7a1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/emoji-decoration.tsx316 B76aafe65e860upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/glass-glow.tsx1.1 KB5799871324f1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/gradient.tsx411 B01b9966a7133upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/hyperframes-brand.html389 Be80932b68891upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/hyperframes.html725 B5f41bc941988upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/italic.tsx526 B2a40fd2ca9bbupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/number-labels.tsx569 B11e3abe73125upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/per-letter-arrays.tsx761 B26105ce8ca08upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/per-letter.tsx873 Be2b6a6cd3ad8upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/serif.tsx748 Beb92be899b5fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/text-pace.tsx777 Bade34bfbf236upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/text-spring-keys.tsx1.0 KBe32a7a906aefupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/text-spring-wrapper.tsx792 B86a7c8484e03upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/text-spring.tsx967 Ba69d6f0a659bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/timeline.ts247 Bc1b3dcf2a025upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/typing-follow.tsx829 B6aa780f4441cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/lint-fixtures/typing-rate.tsx855 Baf4a332c96feupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/pick-fixtures/library.json9.3 KBc9342804c2dbupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/pick.test.py12 KB3cefdd0dde96upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/score.test.py38 KB0048982449feupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/scripts/test/scripts-docs.test.mjs6.1 KBc47153088f6fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/brand-and-color.md40 KBb324bf51f5b0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/camera.md15 KB96eb884e3b86upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/chromium-rendering.md16 KB960abce6e201upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/concept-and-story.md28 KB5b8511620edaupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/copy-and-type.md43 KBea7dde77e59dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/craft-rules.md49 KB52e1075ecbd6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/finishing.md33 KBcf2103a00117upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/formats.md30 KBfdf711060aa9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/hyperframes-engine.md28 KBe8bb42f604d5upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/motion-tokens.md29 KB8c992b1a546eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/product-ui.md24 KB32ef928fe887upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/remotion-engine.md23 KB3b394409598eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/review-loop.md32 KBd896a8f83bf1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/sound.md81 KB3719cc076aecupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/taste-and-slop.md31 KB69669f2f27f1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/technique-library.md465 KBe62bb8431407upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/timing-grid.md29 KB03a63ea36548upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/transitions.md18 KB91d84e8e5a6cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/references/worked-example.md22 KB52b910eccfe7upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/critics/art-copy-ui.md11 KBa1a6cee3a0eeupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/critics/director.md11 KB327aedec1b16upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/critics/forensics.md5.7 KB716476bff0d2upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/critics/sound-sync.md12 KB6bdd00546f08upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/critics/verifier.md3.9 KB342b64104eaaupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/hyperframes-starter/audio/kit.json6.0 KB20ef545dd65aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/hyperframes-starter/audio/score.json576 Bcee8552bd280upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/hyperframes-starter/index.html7.9 KB5e6f26236aabupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/hyperframes-starter/motion.js18 KB40cb38a2c0abupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/hyperframes-starter/package.json1.0 KB12be0354a631upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/hyperframes-starter/setup.mjs1.3 KB19c5b2b501b0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/hyperframes-starter/timeline.js6.8 KBdc5eaff8c404upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/library/techniques.json512 KB37e7b9bd8053upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/.gitignore83 Bab6c807323d1upstream ↗
node_modules/ out/* !out/cues.json public/audio/*.wav .DS_Store *.log __pycache__/skills/cinetic/assets/remotion-starter/audio/kit.json6.0 KB20ef545dd65aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/audio/score.json653 Bd4d2675d51a6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/package.json1.9 KBf2a6ad562794upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/public/audio/.gitkeep0 Be69de29bb2d1upstream ↗
skills/cinetic/assets/remotion-starter/remotion.config.ts2.2 KBb7a68d3768e7upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/acts/Act1.tsx5.5 KB684726205e47upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/acts/Act2.tsx6.0 KB054673cf8891upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/blur.ts1.9 KB08be32b9a762upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/brand/fonts.ts635 B60819a5ae11aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/brand/Mark.tsx3.1 KB740941f1c62dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/brand/tokens.ts3.7 KB6281ca99c2edupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/Film.tsx1.5 KB0ad51640d86cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/FilmSub.tsx1.2 KB765a5fd1e612upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/fx/Chain.tsx10 KB2de086173ebbupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/fx/Cursor.tsx1.9 KBf838aecf4cc4upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/fx/Iris.tsx4.1 KBd0106aa92deaupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/fx/MaskRise.tsx4.7 KBcb4880cbdd22upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/fx/Morph.tsx4.9 KB3559a74a76f7upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/fx/RackFocus.tsx1.7 KB7c6d917464b9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/fx/Roll.tsx6.6 KBd2e66f312726upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/fx/Scramble.tsx6.3 KBa482ce6e006bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/fx/TypeOn.tsx7.0 KBdc1e0ecb5f8cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/fx/Words.tsx4.3 KBb6dbda646f1aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/index.ts109 B91fa0f3468e9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/lib/anim.ts12 KBb7fce7784b9dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/lib/Audit.tsx8.4 KB710d044952e0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/lib/camera.ts4.2 KB7cd561afaaf9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/lib/color.ts2.3 KBaef59831624fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/lib/FontGate.tsx1.5 KBa16aa410f102upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/lib/measure.ts3.0 KB69187e1fd7daupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/lib/pace.ts7.5 KB3c09fea1e28fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/lib/safe.ts3.7 KB2f42cf4badc0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/lib/sync.ts1.7 KBbae328f553d1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/Root.tsx3.1 KBa07eb080452eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/Stills.tsx3.7 KB7dbf33eef652upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/sync.ts5.5 KB98035dbdf21fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/src/timeline.ts8.1 KBa52d3dcdca93upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/remotion-starter/tsconfig.json344 Bfb47f3fd13d1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/assets/TREATMENT.md16 KB07e6d914626bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/cinetic/LICENSE.txt1.0 KB48a2f6640b81upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/leonxlnx-cinetic-skills-cinetic.md