# cinetic

Adopt this skill: https://proskills.md/skills/other/leonxlnx-cinetic-skills-cinetic

Page: https://proskills.md/skills/other/leonxlnx-cinetic-skills-cinetic
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: skills/cinetic/SKILL.md
size: 67199
sha: a457f27b55284fd8f0b54cee3399606f06e8b616
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/SKILL.md
truncated: true

```text
---
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.
```

## File: skills/cinetic/assets/hyperframes-starter/fonts/README.md
size: 2706
sha: 1978e2a90d78cc45168b253de537fb5ece2a6709
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/fonts/README.md

````text
# 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.
````

## File: skills/cinetic/scripts/accumulate.py
size: 19115
sha: 0aa7cbe2f34204800a2d5a65b9da6a1514bc6ef1
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/accumulate.py

```text
#!/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()
```

## File: skills/cinetic/scripts/add-font.mjs
size: 14430
sha: 163c9edbae5d61058e28f82e019b06d4d1b7e4de
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/add-font.mjs

```text
#!/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();
}
```

## File: skills/cinetic/scripts/audio/kit.py
size: 38044
sha: 73aa79f8e2ed1b810be38ace45be1306cccc526d
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/audio/kit.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/audio/kit.py

## File: skills/cinetic/scripts/audio/listen.py
size: 13446
sha: 3a1dacf5bdd5d809434c59f8988083aa8838afc3
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/audio/listen.py

```text
#!/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())
```

## File: skills/cinetic/scripts/audio/master.py
size: 15202
sha: cf9c56547bf145735cafa6f98782427edb870832
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/audio/master.py

```text
#!/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())
```

## File: skills/cinetic/scripts/audio/score.py
size: 115393
sha: f7926335b7c659349cf971b79600601e8159a281
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/audio/score.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/audio/score.py

## File: skills/cinetic/scripts/audio/synth.py
size: 40711
sha: 5134970c096842e16778c7507ca4ddf31be07af3
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/audio/synth.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/audio/synth.py

## File: skills/cinetic/scripts/av-audit.py
size: 50871
sha: 1e5d6222388bb01b38593d29dd2bc01eb665934c
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/av-audit.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/av-audit.py

## File: skills/cinetic/scripts/brand-kit.sh
size: 4291
sha: 8ce4b7a7aa85a2d0f986f7f6f2785f569794b464
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/brand-kit.sh

```text
#!/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' ' ')"
```

## File: skills/cinetic/scripts/brand-svg.ts
size: 6634
sha: 64420c7719c2bdb86be190edc4a263676da2544d
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/brand-svg.ts

```text
/**
 * 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)));
```

## File: skills/cinetic/scripts/check-sync.py
size: 9495
sha: a9a0d54e6ce697ac7a39e0f591742bf32b6f2889
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/check-sync.py

```text
#!/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()
```

## File: skills/cinetic/scripts/deliver.sh
size: 19759
sha: 7091b69533f945a8f968e5bc83c329ab591b74a5
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/deliver.sh

```text
#!/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 $STATUS
```

## File: skills/cinetic/scripts/dither-gradient.py
size: 11767
sha: 737fb1666c2453730627a7b98fcd0f39d1d09c95
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/dither-gradient.py

```text
#!/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()
```

## File: skills/cinetic/scripts/export-cues.ts
size: 11733
sha: 7a11a4fbea87d7406fe1a4321443b1480e3d1c26
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/export-cues.ts

```text
/**
 * 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();
```

## File: skills/cinetic/scripts/forensics.py
size: 63771
sha: a17bbfc9b0eb14a1bae57800a5a86b1701b81fcb
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/forensics.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/forensics.py

## File: skills/cinetic/scripts/grab.sh
size: 2661
sha: 2a44ef56355001a37ed1d3422eec102851d4ee36
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/grab.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/grab.sh

## File: skills/cinetic/scripts/grid-check.ts
size: 32109
sha: af43951dcbaf88dcaa8d3b56e573cf235b519dc8
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/grid-check.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/grid-check.ts

## File: skills/cinetic/scripts/hf-finish.sh
size: 13911
sha: 5765ec4e8c69dd2ddc68c7a5615eb99f9062665a
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/hf-finish.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/hf-finish.sh

## File: skills/cinetic/scripts/hf-preflight.cjs
size: 5297
sha: e2cb48ba622cc3ef6d12924cac76adaa5c9d9acf
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/hf-preflight.cjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/hf-preflight.cjs

## File: skills/cinetic/scripts/layout-audit.sh
size: 15771
sha: f0f0cb2de5300ead02e1b57b4962ab1de3f93180
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/layout-audit.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/layout-audit.sh

## File: skills/cinetic/scripts/lint-film.mjs
size: 59588
sha: af2826c0f7c2aaed028559ed93a70dcd1df7e79d
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/lint-film.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/lint-film.mjs

## File: skills/cinetic/scripts/measure-speed.py
size: 31898
sha: de41cadcfddd11453abd277904515d86be934a81
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/measure-speed.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/measure-speed.py

## File: skills/cinetic/scripts/new-film.sh
size: 14186
sha: 30ec350a1fa6e48a66f8c3d0fc58b252bb0fe9b6
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/new-film.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/new-film.sh

## File: skills/cinetic/scripts/outline-text.py
size: 5004
sha: 0f56efa95b3652bb5db76ba2ccd83be4e78dffa2
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/outline-text.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/outline-text.py

## File: skills/cinetic/scripts/palette.py
size: 17885
sha: 493d4e47d15ffc36fa657391e4afb03bbd5e3ad1
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/palette.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/palette.py

## File: skills/cinetic/scripts/pick.py
size: 21578
sha: 4340cabb2fedae4513bd7ddff82bdd8db097dbf1
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/pick.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/pick.py

## File: skills/cinetic/scripts/probe.py
size: 14004
sha: 8d4c21410072c2dfbb68f3529b6a62b3e76234e8
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/probe.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/probe.py

## File: skills/cinetic/scripts/render-chunks.sh
size: 5913
sha: 2f24752cd6da0fef986960c2bf37851d990ddbe7
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/render-chunks.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/render-chunks.sh

## File: skills/cinetic/scripts/render.sh
size: 20332
sha: 6f1cdf9bea96ff9d93631d54c0c5bff9569c5e62
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/render.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/render.sh

## File: skills/cinetic/scripts/sheet.py
size: 13202
sha: 67b89dae4e191920b1f219a1d0df83a800031fc2
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/sheet.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/sheet.py

## File: skills/cinetic/scripts/test/bash32.test.py
size: 9855
sha: 7e6b85cdfcb64aaea5fdb828d141f60e42332595
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/bash32.test.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/bash32.test.py

## File: skills/cinetic/scripts/test/encode-tags.test.py
size: 4963
sha: a12d518e3fa632791af433968d954d75678681ea
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/encode-tags.test.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/encode-tags.test.py

## File: skills/cinetic/scripts/test/grid-check.test.mjs
size: 11050
sha: 47d915f880aba8426fbcf6831a0177383c82a5a2
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-check.test.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-check.test.mjs

## File: skills/cinetic/scripts/test/grid-fixtures/calm-chains.ts
size: 1230
sha: 080a9d2636044f8351be89064d5f0ba541e57332
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/calm-chains.ts

```text
// 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' },
];
```

## File: skills/cinetic/scripts/test/grid-fixtures/chain-beat.ts
size: 788
sha: 00e035ddd97283d3d73b588e609538c386c2c11f
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/chain-beat.ts

```text
// 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' },
];
```

## File: skills/cinetic/scripts/test/grid-fixtures/chain-gaps.ts
size: 1481
sha: 9f57f408aacdd262281a3b96a335d4b1a83c3f2d
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/chain-gaps.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/chain-gaps.ts

## File: skills/cinetic/scripts/test/grid-fixtures/crisp-legacy.ts
size: 717
sha: defa7a49c0617e8085093eb7d8e8023ba71ebe4b
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/crisp-legacy.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/crisp-legacy.ts

## File: skills/cinetic/scripts/test/grid-fixtures/crisp-soft.ts
size: 1122
sha: ec0726565f56708ffc16f95cf5c61d56ee25d5ce
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/crisp-soft.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/crisp-soft.ts

## File: skills/cinetic/scripts/test/grid-fixtures/lines-together.ts
size: 1002
sha: c42d72196d1c4517b5a30debda208b8ed727383c
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/lines-together.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/lines-together.ts

## File: skills/cinetic/scripts/test/grid-fixtures/read-quiet.ts
size: 616
sha: b10bf1d1192bb509e948530297eedfcf37039a9f
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/read-quiet.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/read-quiet.ts

## File: skills/cinetic/scripts/test/grid-fixtures/reads-and-gaps.ts
size: 603
sha: a053a4d8064fbac8c39435fcd4bbebf5e059a55a
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/reads-and-gaps.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/reads-and-gaps.ts

## File: skills/cinetic/scripts/test/grid-fixtures/replace-exit.ts
size: 866
sha: 413ea3d545fa27433c7bb19e72939339dab04b60
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/replace-exit.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/replace-exit.ts

## File: skills/cinetic/scripts/test/grid-fixtures/sentence-dump.ts
size: 789
sha: 7ff198f6b2e6876760c36c6312d68dc520e7805b
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/sentence-dump.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/grid-fixtures/sentence-dump.ts

## File: skills/cinetic/scripts/test/lint-film.test.mjs
size: 6882
sha: acf1ca517a23c329a04e0161be78921476d5ea85
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-film.test.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-film.test.mjs

## File: skills/cinetic/scripts/test/lint-fixtures/act-copy-literal.tsx
size: 858
sha: 911508815a2b5b728aa1013a1930945837be0df1
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/act-copy-literal.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/act-copy-literal.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/act-sibling-words.tsx
size: 752
sha: 444a75a1aaa65a02c415d867ca5a8b179b608666
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/act-sibling-words.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/act-sibling-words.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/banned-color.ts
size: 1168
sha: c97d99106529f5adc21a544442d7a11b8bb0763b
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/banned-color.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/banned-color.ts

## File: skills/cinetic/scripts/test/lint-fixtures/banned-named.tsx
size: 320
sha: 4ae44e9b58282cdbbfe712566c927727223d780a
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/banned-named.tsx

```text
// 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>
);
```

## File: skills/cinetic/scripts/test/lint-fixtures/brand-file.tsx
size: 357
sha: 68280a9314aea1e80e34e576be357a48ac4e2e5a
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/brand-file.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/brand-file.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/brand-line.tsx
size: 318
sha: 96bc004ec7a14faba9a429c5dd38b1db476f1c24
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/brand-line.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/brand-line.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/emoji-decoration.tsx
size: 316
sha: 76aafe65e8600923cfee75939211c3c106c7c142
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/emoji-decoration.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/emoji-decoration.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/glass-glow.tsx
size: 1148
sha: 5799871324f16805bd0ef8eb7b9a24588da7619f
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/glass-glow.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/glass-glow.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/gradient.tsx
size: 411
sha: 01b9966a71335e61f3562e90a159680f8043e777
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/gradient.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/gradient.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/hyperframes-brand.html
size: 389
sha: e80932b68891b3ce10d59b4f27fdbc14160e716c
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/hyperframes-brand.html

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/hyperframes-brand.html

## File: skills/cinetic/scripts/test/lint-fixtures/hyperframes.html
size: 725
sha: 5f41bc941988697a9d671d34bcbf457da10dac0a
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/hyperframes.html

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/hyperframes.html

## File: skills/cinetic/scripts/test/lint-fixtures/italic.tsx
size: 526
sha: 2a40fd2ca9bb61977827cb8bbd4ca7eed4b28fc1
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/italic.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/italic.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/number-labels.tsx
size: 569
sha: 11e3abe731251ef3f213a01bd02b74247699c809
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/number-labels.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/number-labels.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/per-letter-arrays.tsx
size: 761
sha: 26105ce8ca08e38fd476b499e48b772cc27eb94c
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/per-letter-arrays.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/per-letter-arrays.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/per-letter.tsx
size: 873
sha: e2b6a6cd3ad8250c307ed691bee3390cf47979b3
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/per-letter.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/per-letter.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/serif.tsx
size: 748
sha: eb92be899b5fa2b7b55909dc12865378d7101cb1
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/serif.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/serif.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/text-pace.tsx
size: 777
sha: ade34bfbf2361d5d909e9b0a953ab2cf75930412
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/text-pace.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/text-pace.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/text-spring-keys.tsx
size: 1036
sha: e32a7a906aef2c4230762332c57258bb517f0800
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/text-spring-keys.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/text-spring-keys.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/text-spring-wrapper.tsx
size: 792
sha: 86a7c8484e031d4259e95466fe744fdf78e46c5a
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/text-spring-wrapper.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/text-spring-wrapper.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/text-spring.tsx
size: 967
sha: a69d6f0a659b8ea5a339fedce5d7bbcbb20b9d11
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/text-spring.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/text-spring.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/timeline.ts
size: 247
sha: c1b3dcf2a02505c61d6566f7e1979d4d81adff17
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/timeline.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/timeline.ts

## File: skills/cinetic/scripts/test/lint-fixtures/typing-follow.tsx
size: 829
sha: 6aa780f4441ced729a86a05fd60f1323541ee4d9
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/typing-follow.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/typing-follow.tsx

## File: skills/cinetic/scripts/test/lint-fixtures/typing-rate.tsx
size: 855
sha: af4a332c96fe2acb6ff38d9da3a1d78d3a5b2e65
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/typing-rate.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/lint-fixtures/typing-rate.tsx

## File: skills/cinetic/scripts/test/pick-fixtures/library.json
size: 9560
sha: c9342804c2dbd6447c7b7626cdd15dd0c468025f
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/pick-fixtures/library.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/pick-fixtures/library.json

## File: skills/cinetic/scripts/test/pick.test.py
size: 12790
sha: 3cefdd0dde96dd1251c1a99c8803b61974943859
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/pick.test.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/pick.test.py

## File: skills/cinetic/scripts/test/score.test.py
size: 39273
sha: 0048982449feb09c0c821ba004db4aa78e6f0270
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/score.test.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/score.test.py

## File: skills/cinetic/scripts/test/scripts-docs.test.mjs
size: 6275
sha: c47153088f6fd2b62ba7cfe1e23e254770c54531
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/scripts-docs.test.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/scripts/test/scripts-docs.test.mjs

## File: skills/cinetic/references/brand-and-color.md
size: 41215
sha: b324bf51f5b0798e8172a6f75d6c7dd4d5eb2126
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/brand-and-color.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/brand-and-color.md

## File: skills/cinetic/references/camera.md
size: 14961
sha: 96eb884e3b8660a88414697ac89000fc515a3207
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/camera.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/camera.md

## File: skills/cinetic/references/chromium-rendering.md
size: 16628
sha: 960abce6e201518a9f91d88bf7c616aa64717500
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/chromium-rendering.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/chromium-rendering.md

## File: skills/cinetic/references/concept-and-story.md
size: 28302
sha: 5b8511620eda47e807d563977e38248a7e658f11
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/concept-and-story.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/concept-and-story.md

## File: skills/cinetic/references/copy-and-type.md
size: 43654
sha: ea7dde77e59d195257d3d39b3aaf96afcfe19ad0
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/copy-and-type.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/copy-and-type.md

## File: skills/cinetic/references/craft-rules.md
size: 49691
sha: 52e1075ecbd69c207a28c4794a7d9553a3f2aca2
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/craft-rules.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/craft-rules.md

## File: skills/cinetic/references/finishing.md
size: 33444
sha: cf2103a0011741e720f96b972ce3e52b32173149
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/finishing.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/finishing.md

## File: skills/cinetic/references/formats.md
size: 31190
sha: fdf711060aa9027a19b3c977b1e4957656d88793
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/formats.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/formats.md

## File: skills/cinetic/references/hyperframes-engine.md
size: 29092
sha: e8bb42f604d51d91064572a6213cc112cdf8cc42
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/hyperframes-engine.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/hyperframes-engine.md

## File: skills/cinetic/references/motion-tokens.md
size: 29730
sha: 8c992b1a546e9e9ca0805b765c50f2bc4c1766bd
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/motion-tokens.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/motion-tokens.md

## File: skills/cinetic/references/product-ui.md
size: 24913
sha: 32ef928fe8873b0972e5b523da9cefbd73bdd995
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/product-ui.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/product-ui.md

## File: skills/cinetic/references/remotion-engine.md
size: 23077
sha: 3b394409598e411085a06b2927142916f69c6772
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/remotion-engine.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/remotion-engine.md

## File: skills/cinetic/references/review-loop.md
size: 32328
sha: d896a8f83bf1eec101e2dd21afa0cc02c1b000a5
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/review-loop.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/review-loop.md

## File: skills/cinetic/references/sound.md
size: 82879
sha: 3719cc076aecb8cf902c9537cbcdeeae24741e6c
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/sound.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/sound.md

## File: skills/cinetic/references/taste-and-slop.md
size: 31977
sha: 69669f2f27f1975aec1824989d1b76e80df5d6bd
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/taste-and-slop.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/taste-and-slop.md

## File: skills/cinetic/references/technique-library.md
size: 475918
sha: e62bb843140798fae13692de39311119ec1a2347
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/technique-library.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/technique-library.md

## File: skills/cinetic/references/timing-grid.md
size: 29782
sha: 03a63ea365483e1511538170d627fb712636bde1
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/timing-grid.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/timing-grid.md

## File: skills/cinetic/references/transitions.md
size: 18943
sha: 91d84e8e5a6c52092f1336155924e02f54037e25
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/transitions.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/transitions.md

## File: skills/cinetic/references/worked-example.md
size: 22364
sha: 52b910eccfe70d4bf0444ec926b89e0a6cf05617
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/worked-example.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/references/worked-example.md

## File: skills/cinetic/assets/critics/art-copy-ui.md
size: 11340
sha: a1a6cee3a0ee6ce0c39c9771cb883fcf7be27ad0
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/critics/art-copy-ui.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/critics/art-copy-ui.md

## File: skills/cinetic/assets/critics/director.md
size: 10941
sha: 327aedec1b1614658415e38c44e2e49e0874515d
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/critics/director.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/critics/director.md

## File: skills/cinetic/assets/critics/forensics.md
size: 5880
sha: 716476bff0d28ae6aea75d0c6aa179932dbab596
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/critics/forensics.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/critics/forensics.md

## File: skills/cinetic/assets/critics/sound-sync.md
size: 12748
sha: 6bdd00546f082d03f3adfe69d6c1d552aa0da563
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/critics/sound-sync.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/critics/sound-sync.md

## File: skills/cinetic/assets/critics/verifier.md
size: 3986
sha: 342b64104eaa97358ef05e27606e704f1f3c485a
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/critics/verifier.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/critics/verifier.md

## File: skills/cinetic/assets/hyperframes-starter/audio/kit.json
size: 6147
sha: 20ef545dd65a00270ce3015647be2ad4b01aa0b5
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/audio/kit.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/audio/kit.json

## File: skills/cinetic/assets/hyperframes-starter/audio/score.json
size: 576
sha: cee8552bd2808e74692db271e7df5cfeced5d812
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/audio/score.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/audio/score.json

## File: skills/cinetic/assets/hyperframes-starter/index.html
size: 8044
sha: 5e6f26236aab8498bb9bd5527518968f3e55f878
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/index.html

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/index.html

## File: skills/cinetic/assets/hyperframes-starter/motion.js
size: 18443
sha: 40cb38a2c0ab9cf6ac60df4e0d6f69876643635b
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/motion.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/motion.js

## File: skills/cinetic/assets/hyperframes-starter/package.json
size: 1041
sha: 12be0354a631f07ac56f99171122b41d720aaae9
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/package.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/package.json

## File: skills/cinetic/assets/hyperframes-starter/setup.mjs
size: 1365
sha: 19c5b2b501b042946823818ddda55d16e0b7e77d
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/setup.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/setup.mjs

## File: skills/cinetic/assets/hyperframes-starter/timeline.js
size: 6968
sha: dc5eaff8c404078198351b8a5db4e43cd4ab7d8e
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/timeline.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/hyperframes-starter/timeline.js

## File: skills/cinetic/assets/library/techniques.json
size: 524625
sha: 37e7b9bd80539285dbd0e0a0af51f9b0c883155c
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/library/techniques.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/library/techniques.json

## File: skills/cinetic/assets/remotion-starter/.gitignore
size: 83
sha: ab6c807323d1c14e0ec59055cd8703c443dac73e
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/.gitignore

```text
node_modules/
out/*
!out/cues.json
public/audio/*.wav
.DS_Store
*.log
__pycache__/
```

## File: skills/cinetic/assets/remotion-starter/audio/kit.json
size: 6147
sha: 20ef545dd65a00270ce3015647be2ad4b01aa0b5
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/audio/kit.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/audio/kit.json

## File: skills/cinetic/assets/remotion-starter/audio/score.json
size: 653
sha: d4d2675d51a6157e71022a5bd2a584a34074e0f0
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/audio/score.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/audio/score.json

## File: skills/cinetic/assets/remotion-starter/package.json
size: 1902
sha: f2a6ad562794193e0922f856b88f0b3d28e4784b
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/package.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/package.json

## File: skills/cinetic/assets/remotion-starter/public/audio/.gitkeep
size: 0
sha: e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/public/audio/.gitkeep

```text

```

## File: skills/cinetic/assets/remotion-starter/remotion.config.ts
size: 2268
sha: b7a68d3768e7e3ac3bf864cab5b7376a028699ed
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/remotion.config.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/remotion.config.ts

## File: skills/cinetic/assets/remotion-starter/src/acts/Act1.tsx
size: 5604
sha: 684726205e47c00feb0ea7f67d2a3e4741340f68
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/acts/Act1.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/acts/Act1.tsx

## File: skills/cinetic/assets/remotion-starter/src/acts/Act2.tsx
size: 6107
sha: 054673cf8891e2d1a697fb23be49ea4ded35070c
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/acts/Act2.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/acts/Act2.tsx

## File: skills/cinetic/assets/remotion-starter/src/blur.ts
size: 1924
sha: 08be32b9a7627e254c8552a8eb7e168c07de33d0
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/blur.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/blur.ts

## File: skills/cinetic/assets/remotion-starter/src/brand/fonts.ts
size: 635
sha: 60819a5ae11aade93bcf96de003c40c3cdb8bffc
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/brand/fonts.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/brand/fonts.ts

## File: skills/cinetic/assets/remotion-starter/src/brand/Mark.tsx
size: 3149
sha: 740941f1c62d447adfa6f9e9f79cad257185b6fa
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/brand/Mark.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/brand/Mark.tsx

## File: skills/cinetic/assets/remotion-starter/src/brand/tokens.ts
size: 3808
sha: 6281ca99c2edb0ea2766811e1b040c790079ce8b
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/brand/tokens.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/brand/tokens.ts

## File: skills/cinetic/assets/remotion-starter/src/Film.tsx
size: 1535
sha: 0ad51640d86c29ac01d5207128c1e4e6b9b0eccc
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/Film.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/Film.tsx

## File: skills/cinetic/assets/remotion-starter/src/FilmSub.tsx
size: 1234
sha: 765a5fd1e61291162e80929e98a8a6649757ae07
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/FilmSub.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/FilmSub.tsx

## File: skills/cinetic/assets/remotion-starter/src/fx/Chain.tsx
size: 10741
sha: 2de086173ebb3db2e1975f275b91c245c0f21e9e
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Chain.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Chain.tsx

## File: skills/cinetic/assets/remotion-starter/src/fx/Cursor.tsx
size: 1966
sha: f838aecf4cc4182710d0824062ea96cef55f9924
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Cursor.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Cursor.tsx

## File: skills/cinetic/assets/remotion-starter/src/fx/Iris.tsx
size: 4193
sha: d0106aa92deae77938fe3f2b538eb4906be4dcdc
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Iris.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Iris.tsx

## File: skills/cinetic/assets/remotion-starter/src/fx/MaskRise.tsx
size: 4824
sha: cb4880cbdd22dcdb183d89a7abf5c8c04db60101
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/MaskRise.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/MaskRise.tsx

## File: skills/cinetic/assets/remotion-starter/src/fx/Morph.tsx
size: 5006
sha: 3559a74a76f70c931ae0455f2dc44339f2807395
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Morph.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Morph.tsx

## File: skills/cinetic/assets/remotion-starter/src/fx/RackFocus.tsx
size: 1772
sha: 7c6d917464b901b6b6b2278c8ab237579dcab53a
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/RackFocus.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/RackFocus.tsx

## File: skills/cinetic/assets/remotion-starter/src/fx/Roll.tsx
size: 6747
sha: d2e66f31272603578050f708d4b01d75399f9f45
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Roll.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Roll.tsx

## File: skills/cinetic/assets/remotion-starter/src/fx/Scramble.tsx
size: 6427
sha: a482ce6e006bc45e077e9bf97c4fc73d8045caab
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Scramble.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Scramble.tsx

## File: skills/cinetic/assets/remotion-starter/src/fx/TypeOn.tsx
size: 7185
sha: dc1e0ecb5f8cceac207bb5fede77626284e267f1
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/TypeOn.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/TypeOn.tsx

## File: skills/cinetic/assets/remotion-starter/src/fx/Words.tsx
size: 4428
sha: b6dbda646f1aeb06ea5acafe1c567ec8c8c6e973
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Words.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/fx/Words.tsx

## File: skills/cinetic/assets/remotion-starter/src/index.ts
size: 109
sha: 91fa0f3468e91d9cd078de3983976508c3c020e5
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/index.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/index.ts

## File: skills/cinetic/assets/remotion-starter/src/lib/anim.ts
size: 11912
sha: b7fce7784b9d05bfe58009c85cf78bab0a909824
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/anim.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/anim.ts

## File: skills/cinetic/assets/remotion-starter/src/lib/Audit.tsx
size: 8557
sha: 710d044952e0b183c1788f4d6f89354a4fedd938
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/Audit.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/Audit.tsx

## File: skills/cinetic/assets/remotion-starter/src/lib/camera.ts
size: 4290
sha: 7cd561afaaf94e8f88fd8cacb727a6edbb18177a
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/camera.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/camera.ts

## File: skills/cinetic/assets/remotion-starter/src/lib/color.ts
size: 2311
sha: aef59831624f73a0c73c1434e40865bfa0adc997
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/color.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/color.ts

## File: skills/cinetic/assets/remotion-starter/src/lib/FontGate.tsx
size: 1517
sha: a16aa410f102391305cf9fa8e1460a9858477466
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/FontGate.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/FontGate.tsx

## File: skills/cinetic/assets/remotion-starter/src/lib/measure.ts
size: 3051
sha: 69187e1fd7da2e312da12dc503c22f7f776d50e8
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/measure.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/measure.ts

## File: skills/cinetic/assets/remotion-starter/src/lib/pace.ts
size: 7715
sha: 3c09fea1e28fde99836c462808bc571709b0e703
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/pace.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/pace.ts

## File: skills/cinetic/assets/remotion-starter/src/lib/safe.ts
size: 3813
sha: 2f42cf4badc00b3629b1c3ebe318cc1743789aca
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/safe.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/safe.ts

## File: skills/cinetic/assets/remotion-starter/src/lib/sync.ts
size: 1768
sha: bae328f553d1d13763049da3e03ed8db6eac6987
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/sync.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/lib/sync.ts

## File: skills/cinetic/assets/remotion-starter/src/Root.tsx
size: 3172
sha: a07eb080452e7fe50f411f30cfddd435f793b84a
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/Root.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/Root.tsx

## File: skills/cinetic/assets/remotion-starter/src/Stills.tsx
size: 3788
sha: 7dbf33eef6527469258e21ab4e3d3ab6e2f5e667
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/Stills.tsx

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/Stills.tsx

## File: skills/cinetic/assets/remotion-starter/src/sync.ts
size: 5639
sha: 98035dbdf21f18260ecd7011da90ac8c2739f9da
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/sync.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/sync.ts

## File: skills/cinetic/assets/remotion-starter/src/timeline.ts
size: 8290
sha: a52d3dcdca937d8cee85c43f118a161027d12d25
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/timeline.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/src/timeline.ts

## File: skills/cinetic/assets/remotion-starter/tsconfig.json
size: 344
sha: fb47f3fd13d1d082d73ab2e8fe25489743410f89
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/tsconfig.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/remotion-starter/tsconfig.json

## File: skills/cinetic/assets/TREATMENT.md
size: 16662
sha: 07e6d914626bb816f9cb283e275410ee956dcfd6
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/TREATMENT.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/assets/TREATMENT.md

## File: skills/cinetic/LICENSE.txt
size: 1065
sha: 48a2f6640b81ff8eca9ce7f6a96337692713ef5b
upstream: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/LICENSE.txt

Content is not inlined in the catalog. Read the upstream file: https://github.com/leonxlnx/cinetic/blob/main/skills/cinetic/LICENSE.txt
