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. -…

, pf)\n bits = int(m.group(1)) if m else 8\n yuv = pf.startswith(('yuv', 'yuvj', 'gray', 'nv'))\n cmd = ['ffmpeg', '-v', 'error', '-nostdin']\n if start > 0:\n cmd += ['-ss', f'{(start - 0.5) / info[\"fps\"]:.6f}']\n cmd += ['-i', path, '-map', '0:v:0']\n if count: # count source frames; after the select that is ceil(count / every) sampled ones\n cmd += ['-frames:v', str(-(-count // every))]\n sel = f'select=not(mod(n\\\\,{every}))'\n out_fmt = 'gray' if bits == 8 else f'gray{bits}le'\n cmd += ['-vf', f'{sel},extractplanes=y' if yuv else f'{sel},format=gray', '-fps_mode', 'passthrough',\n '-f', 'rawvideo', '-pix_fmt', out_fmt if yuv else 'gray', '-']\n w, h = info['w'], info['h']\n wide = yuv and bits > 8\n nbytes = w * h * (2 if wide else 1)\n p = subprocess.Popen(cmd, stdout=subprocess.PIPE)\n i = start\n try:\n while True:\n buf = p.stdout.read(nbytes)\n if len(buf) \u003c nbytes:\n break\n a = np.frombuffer(buf, '\u003cu2' if wide else np.uint8).reshape(h, w).astype(np.float32)\n yield i, (a / (1 \u003c\u003c (bits - 8)) if wide else a)\n i += every\n finally:\n p.stdout.close()\n p.kill()\n p.wait()\n\n\nSMEAR_B = 16 # macroblock size: encoder decisions (keep the texture or flatten it) are made per block\n\n\ndef smear_frame(y, min_region=0.01, near=2, mode_min=0.5, tex_hi=0.3, tex_lo=0.02):\n \"\"\"Compression smear on flat fields. A region counts as a flat field when the 17x17 neighbourhood\n of each of its pixels spans \u003c= 3 code values, it covers >= min_region of the frame, one code value\n holds >= half of it and >= 98% sits within +-near of that value (a truly flat colour, not a\n gradient, texture or blurred UI). In such a field every 16 px block should look alike: all clean\n (an exact encode) or all finely textured (uniform dither or grain). Smear is the mix: blocks whose\n pixels differ from a neighbour on >= tex_hi of the block next to blocks with \u003c= tex_lo. Returns\n the smeared share of the frame (min(textured, clean) x the field's blocks / all blocks).\"\"\"\n H, W = y.shape\n y8 = np.clip(np.round(y), 0, 255).astype(np.uint8)\n k = np.ones((17, 17), np.uint8)\n flat = ((cv2.dilate(y8, k).astype(np.int16) - cv2.erode(y8, k)) \u003c= 3).astype(np.uint8)\n n, lab, st, _ = cv2.connectedComponentsWithStats(flat, connectivity=4)\n diff = np.zeros((H, W), bool)\n diff[:, :-1] |= np.abs(np.diff(y, axis=1)) > 0.5\n diff[:-1] |= np.abs(np.diff(y, axis=0)) > 0.5\n th, tw = H // SMEAR_B, W // SMEAR_B\n if th == 0 or tw == 0:\n return dict(area=0.0, patchy=0.0, field=0.0, level=None)\n\n def blocks(a):\n return a[:th * SMEAR_B, :tw * SMEAR_B].reshape(th, SMEAR_B, tw, SMEAR_B).mean(axis=(1, 3))\n\n tex = blocks(diff.astype(np.float32))\n best = dict(area=0.0, patchy=0.0, field=0.0, level=None)\n for c in range(1, n):\n if st[c, cv2.CC_STAT_AREA] \u003c min_region * W * H:\n continue\n inside = lab == c\n full = blocks(inside.astype(np.float32)) >= 0.999\n if full.sum() \u003c 50:\n continue\n hist = np.bincount(y8[inside], minlength=256)\n mode = int(hist.argmax())\n if hist[mode] \u003c mode_min * hist.sum() or hist[max(0, mode - near):mode + near + 1].sum() \u003c 0.98 * hist.sum():\n continue # a gradient, a texture or soft content: the banding check's business, not this one's\n t = tex[full]\n patchy = min(float((t >= tex_hi).mean()), float((t \u003c= tex_lo).mean()))\n area = patchy * float(full.sum()) / (th * tw)\n if area > best['area'] or best['level'] is None:\n best = dict(area=round(area, 4), patchy=round(patchy, 3), field=round(float(full.sum()) / (th * tw), 3),\n level=mode)\n return best\n\n\ndef smear_scan(args, info):\n \"\"\"Sample frames for the smear check; returns [{frame, area, patchy, field, level, _img}].\"\"\"\n start = max(0, args.from_frame or 0)\n count = max(1, args.to_frame - start + 1) if args.to_frame is not None else None\n every = args.smear_every or max(1, int(round(info['fps'] / 2)))\n out = []\n for f, y in decode_y(args.video, info, start, count, every):\n r = smear_frame(y)\n lvl = r['level'] if r['level'] is not None else float(np.median(y))\n st = np.clip((y - lvl) * 25 + 128, 0, 255).astype(np.uint8)\n r['_img'] = cv2.cvtColor(cv2.resize(st, (480, int(480 * info['h'] / info['w'])), interpolation=cv2.INTER_AREA),\n cv2.COLOR_GRAY2BGR)\n out.append(dict(frame=f, **r))\n return out\n\n\n# ------------------------------------------------------------------------------------------\n# cues (shared with av-audit.py): the new {events:[...]} schema or an older per-key layout\n# ------------------------------------------------------------------------------------------\ndef _num(x):\n return isinstance(x, (int, float)) and not isinstance(x, bool)\n\n\ndef load_cues(path):\n \"\"\"Normalise a cues file to {fps,total,acts,cue,events:[{f,kind,weight,apex,ends,pan}],qa,schema}.\n\n New schema: {fps,bpm,total,acts:{name:{from,dur}},cue:{name:f},events:[{f,kind,weight,pan,pitch?,apexFrac?}]}.\n Older exports (per-key lists such as sfx:{name:f|[f]}, peaks:{name:f}, landings:[{f}], rain:[f]) are\n read too: every integer frame list or name->frame map becomes events of that kind; `peaks` and\n whoosh-like names are apex events (the sound's loudest point sits on the frame).\n Event shapes: apex (apexFrac given, or whoosh/suck) peaks on f; ends (riser, swell) builds\n into f; everything else starts on f.\n \"\"\"\n try:\n c = json.load(open(path))\n except (OSError, json.JSONDecodeError) as e:\n die(f'cannot read cues {path}: {e}')\n total = int(c.get('total') or 0)\n acts = {k: (int(v['from']), int(v['from']) + int(v.get('dur', 0)))\n for k, v in (c.get('acts') or {}).items() if isinstance(v, dict) and 'from' in v}\n cue = {k: int(round(v)) for k, v in (c.get('cue') or {}).items() if _num(v)}\n events = []\n\n def is_apex(kind):\n return any(a in kind.lower() for a in APEX_KINDS)\n\n def is_end(kind):\n return any(a in kind.lower() for a in END_KINDS)\n\n if isinstance(c.get('events'), list):\n schema = 'events'\n for e in c['events']:\n if not isinstance(e, dict) or not _num(e.get('f')):\n continue\n kind = str(e.get('kind', 'hit'))\n apex = ('apexFrac' in e) or is_apex(kind)\n events.append(dict(f=float(e['f']), kind=kind, weight=float(e.get('weight', 1.0)),\n apex=apex, ends=is_end(kind) and not apex, pan=e.get('pan'), variant=e.get('variant')))\n else:\n schema = 'legacy'\n skip = {'fps', 'total', 'bpm', 'acts', 'cue', 'events', 'qa', 'meta', 'version'}\n\n def framey(v):\n return _num(v) and float(v).is_integer() and v >= 0 and (not total or v \u003c= total + 1)\n\n for key, val in c.items():\n if key in skip:\n continue\n if isinstance(val, list) and val and all(framey(v) for v in val):\n events += [dict(f=float(v), kind=key, weight=0.5, apex=is_apex(key), ends=is_end(key), pan=None)\n for v in val]\n elif isinstance(val, list) and val and all(isinstance(v, dict) and framey(v.get('f')) for v in val):\n events += [dict(f=float(v['f']), kind=key, weight=1.0 if v.get('heavy') else 0.6,\n apex=is_apex(key), ends=is_end(key), pan=v.get('pan')) for v in val]\n elif isinstance(val, dict):\n for name, v in val.items():\n apex = key == 'peaks' or is_apex(name)\n for x in (v if isinstance(v, list) else [v]):\n if framey(x):\n events.append(dict(f=float(x), kind=name, weight=1.0, apex=apex,\n ends=is_end(name) and not apex, pan=None))\n events.sort(key=lambda e: (e['f'], e['kind']))\n copy = [x for x in (c.get('copy') or []) if isinstance(x, dict) and _num(x.get('resolved')) and _num(x.get('out'))]\n return dict(fps=float(c.get('fps') or 0) or None, total=total, acts=acts, cue=cue, events=events,\n qa=c.get('qa') if isinstance(c.get('qa'), dict) else None, schema=schema, copy=copy,\n pace=c.get('pace') if c.get('pace') in PACE_LIMITS else None)\n\n\n# ------------------------------------------------------------------------------------------\n# declarations\n# ------------------------------------------------------------------------------------------\ndef parse_ranges(text):\n \"\"\"'345-359,600' -> [(345, 359), (600, 600)]\"\"\"\n out = []\n for part in filter(None, (x.strip() for x in (text or '').split(','))):\n try:\n if '-' in part[1:]:\n a, b = part.split('-', 1)\n out.append((int(a), int(b)))\n else:\n out.append((int(part), int(part)))\n except ValueError:\n die(f'bad frame range: {part}')\n return out\n\n\ndef _pairs(items):\n out = []\n for x in items or []:\n if _num(x):\n out.append((int(x), int(x)))\n elif isinstance(x, (list, tuple)) and len(x) == 2:\n out.append((int(x[0]), int(x[1])))\n else:\n raise ValueError(x)\n return out\n\n\ndef load_declarations(args):\n cuts = parse_ranges(args.cuts)\n freezes = parse_ranges(args.freeze_ok)\n ignore = parse_ranges(args.ignore)\n acts, moments, reading, cues_fps = {}, [], [], None\n\n def take(block, where):\n if not isinstance(block, dict):\n return\n try:\n cuts.extend(_pairs(block.get('cuts')))\n freezes.extend(_pairs(block.get('freezes')))\n ignore.extend(_pairs(block.get('ignore')))\n except (TypeError, ValueError):\n die(f'bad qa block in {where}: expected cuts:[f|[a,b]], freezes:[[a,b]], ignore:[[a,b]]')\n\n if args.declare:\n try:\n take(json.load(open(args.declare)), args.declare)\n except (OSError, json.JSONDecodeError) as e:\n die(f'cannot read --declare {args.declare}: {e}')\n if args.cues:\n c = load_cues(args.cues)\n take(c['qa'], args.cues)\n cuts.extend((int(round(e['f'])), int(round(e['f']))) for e in c['events'] if e['kind'].lower() == 'cut')\n acts = c['acts']\n moments = sorted({int(round(e['f'])) for e in c['events']} | set(c['cue'].values()))\n reading = [w for x in c['copy'] for w in read_windows(x) if w[1] > w[0]]\n cues_fps = c['fps']\n return sorted(set(cuts)), sorted(set(freezes)), sorted(set(ignore)), acts, moments, reading, cues_fps\n\n\ndef read_windows(x):\n \"\"\"The frames in which a copy entry is read: its `read` windows (export-cues.ts writes the\n timeline's READ: an earlier fragment of a chain only for its own hold, so a frozen picture beat\n between two fragments is not excused as a read), or [resolved, out) from an older cues.json.\"\"\"\n read = x.get('read')\n if isinstance(read, list) and all(isinstance(w, (list, tuple)) and len(w) == 2 for w in read):\n return [(int(a), int(b)) for a, b in read]\n return [(int(x['resolved']), int(x['out']))]\n\n\ndef in_ranges(f, ranges, pad=0):\n return any(a - pad \u003c= f \u003c= b + pad for a, b in ranges)\n\n\ndef act_of(f, acts):\n for name, (a, b) in acts.items():\n if a \u003c= f \u003c b:\n return name\n return None\n\n\n# ------------------------------------------------------------------------------------------\n# sheet helpers (labelled tiles), shared with sheet.py\n# ------------------------------------------------------------------------------------------\ndef label(img, text, sub=None):\n font, sc = cv2.FONT_HERSHEY_SIMPLEX, max(0.4, img.shape[1] / 900)\n (tw, th), base = cv2.getTextSize(text, font, sc, 1)\n cv2.rectangle(img, (0, 0), (tw + 8, th + base + 6), (0, 0, 0), -1)\n cv2.putText(img, text, (4, th + 3), font, sc, (255, 255, 255), 1, cv2.LINE_AA)\n if sub:\n (sw, sh), sb = cv2.getTextSize(sub, font, sc * 0.85, 1)\n y0 = th + base + 6\n cv2.rectangle(img, (0, y0), (sw + 8, y0 + sh + sb + 6), (40, 40, 40), -1)\n cv2.putText(img, sub, (4, y0 + sh + 3), font, sc * 0.85, (120, 230, 255), 1, cv2.LINE_AA)\n return img\n\n\ndef tile(images, cols, pad=4, bg=(90, 90, 90)):\n h, w = images[0].shape[:2]\n rows = (len(images) + cols - 1) // cols\n out = np.full((rows * h + (rows + 1) * pad, cols * w + (cols + 1) * pad, 3), bg, np.uint8)\n for i, im in enumerate(images):\n r, c = divmod(i, cols)\n y, x = pad + r * (h + pad), pad + c * (w + pad)\n out[y:y + h, x:x + w] = im\n return out\n\n\n# ------------------------------------------------------------------------------------------\n# measurements\n# ------------------------------------------------------------------------------------------\ndef grid_boxes(w, h, n=3, size=256):\n size = min(size, w // n, h // n)\n return [(int(w * (i + 0.5) / n) - size // 2, int(h * (j + 0.5) / n) - size // 2, size, size)\n for j in range(n) for i in range(n)]\n\n\ndef edge_depth(rows, delta, frac):\n \"\"\"rows[0] is the outermost line of an edge. Returns how many outer lines (1-3) differ from\n rows 5-7, or 0 when nothing differs or the reference rows themselves disagree (an edge of\n content passing through, not a sliver).\"\"\"\n if (np.abs(rows[5] - rows[7]) > delta).mean() > frac / 2:\n return 0\n inner = rows[5:8].mean(0)\n depth = 0\n for r in range(4):\n if (np.abs(rows[r] - inner) > delta).mean() > frac:\n depth = r + 1\n else:\n break\n return depth if depth \u003c= 3 else 0\n\n\nBAND_T = 64\n\n\ndef banding_tiles(gray):\n \"\"\"Boolean map of 64 px tiles that hold a real low-frequency gradient (1-12 levels across the\n tile) rendered as flat 8-bit plateaus split by 1-level contours: visible bands. Dithered\n gradients are not flat; flat fills have no gradient; edges and texture are not smooth.\"\"\"\n k = np.ones((5, 5), np.uint8)\n rng = cv2.dilate(gray, k).astype(np.int16) - cv2.erode(gray, k)\n th, tw = gray.shape[0] // BAND_T, gray.shape[1] // BAND_T\n\n def t(a):\n return a[:th * BAND_T, :tw * BAND_T].reshape(th, BAND_T, tw, BAND_T).mean(axis=(1, 3))\n\n smooth, flat, step = t(rng \u003c= 2), t(rng == 0), t((rng >= 1) & (rng \u003c= 2))\n q = BAND_T // 4\n lo = cv2.GaussianBlur(cv2.resize(gray, (gray.shape[1] // 4, gray.shape[0] // 4),\n interpolation=cv2.INTER_AREA).astype(np.float32), (0, 0), 4)\n lo = lo[:th * q, :tw * q].reshape(th, q, tw, q)\n span = lo.max(axis=(1, 3)) - lo.min(axis=(1, 3))\n return (smooth > 0.97) & (flat > 0.5) & (flat \u003c 0.97) & (step > 0.03) & (span >= 1.0) & (span \u003c= 12)\n\n\ndef stretch(gray, gain=12):\n med = float(np.median(gray))\n return np.clip((gray.astype(np.float32) - med) * gain + 128, 0, 255).astype(np.uint8)\n\n\ndef flow_residual(small_prev, small_cur):\n \"\"\"How much of the change from prev to cur optical flow cannot explain (0 = pure motion, 1 = none).\"\"\"\n a = cv2.cvtColor(small_prev, cv2.COLOR_RGB2GRAY)\n b = cv2.cvtColor(small_cur, cv2.COLOR_RGB2GRAY)\n diff = np.abs(b.astype(np.float32) - a)\n m = diff > 12\n if m.sum() \u003c 10:\n return 0.0\n fl = cv2.calcOpticalFlowFarneback(b, a, None, 0.5, 5, 25, 5, 7, 1.5, 0)\n yy, xx = np.mgrid[0:a.shape[0], 0:a.shape[1]].astype(np.float32)\n warped = cv2.remap(a, xx + fl[..., 0], yy + fl[..., 1], cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)\n res = np.abs(b.astype(np.float32) - warped)\n return float(res[m].mean() / diff[m].mean())\n\n\ndef stairs(v, r, first, win, rmin):\n \"\"\"Windows of a patch track that move in whole-pixel stairs during a slow move: rests (|u| \u003c 0.15)\n with isolated 1 px jumps between them. Smooth acceleration (rests, then a run of ~1 px steps)\n is not stairs, so at least two jumps must have a rest on both sides.\"\"\"\n hits = []\n for s in range(0, max(0, len(v) - win + 1), max(1, win // 2)):\n seg, rr = v[s:s + win], r[s:s + win]\n if np.isnan(seg).any() or (rr \u003c rmin).mean() > 0.1:\n continue\n disp = seg.sum(0)\n D = float(np.hypot(*disp))\n if D \u003c 2.0 or D / win > 0.9:\n continue\n u = seg @ (disp / D)\n rest, jump = np.abs(u) \u003c 0.15, np.abs(u - 1) \u003c 0.2\n isolated = sum(1 for i in range(1, win - 1) if jump[i] and rest[i - 1] and rest[i + 1])\n if isolated >= 2 and (rest | jump).mean() >= 0.9:\n hits.append(dict(frm=first + s, to=first + s + win - 1, speed=round(D / win, 3),\n jumps=int(jump.sum()), isolated=isolated))\n merged = []\n for h in hits:\n if merged and h['frm'] \u003c= merged[-1]['to'] + 1:\n merged[-1]['to'] = h['to']\n merged[-1]['jumps'] = max(merged[-1]['jumps'], h['jumps'])\n merged[-1]['isolated'] = max(merged[-1]['isolated'], h['isolated'])\n else:\n merged.append(dict(h))\n return [{'from': m.pop('frm'), **m} for m in merged]\n\n\ndef rolling_median_excl(d, i, r=4):\n lo, hi = max(1, i - r), min(len(d), i + r + 1)\n nb = np.concatenate([d[lo:i], d[i + 1:hi]])\n return float(np.median(nb)) if len(nb) else 0.0\n\n\ndef runs(mask):\n \"\"\"[(start, end_inclusive)] of True runs.\"\"\"\n out, s = [], None\n for i, v in enumerate(mask):\n if v and s is None:\n s = i\n elif not v and s is not None:\n out.append((s, i - 1))\n s = None\n if s is not None:\n out.append((s, len(mask) - 1))\n return out\n\n\n# ------------------------------------------------------------------------------------------\n# pass 1: decode once, measure everything per frame\n# ------------------------------------------------------------------------------------------\ndef analyse(args, info):\n fps, W, H = info['fps'], info['w'], info['h']\n start = max(0, args.from_frame or 0)\n count = max(1, args.to_frame - start + 1) if args.to_frame is not None else None\n hw, hh = W // 2, H // 2\n gscale = (W * H) / (hw * hh) # ghost pixel counts are reported at full-res equivalent\n sboxes = [(int(hw * i / 3), int(hh * j / 3), hw // 3, hh // 3) for j in range(3) for i in range(3)]\n rboxes = [(int(SW * i / 3), int(SH * j / 3), SW // 3, SH // 3) for j in range(3) for i in range(3)]\n tracks = {} if args.no_judder else {f'p{k}': b for k, b in enumerate(grid_boxes(W, H, 3, 256))}\n for k, t in enumerate(args.track or []):\n try:\n x, y, w, h = [int(float(q)) for q in t.split(',')]\n except ValueError:\n die(f'bad --track {t}: expected x,y,w,h')\n if x \u003c 0 or y \u003c 0 or w \u003c 16 or h \u003c 16 or x + w > W or y + h > H:\n die(f'--track {t} is outside the {W}x{H} frame')\n tracks[f'track{k}'] = (x, y, w, h)\n hann = {k: cv2.createHanningWindow((b[2], b[3]), cv2.CV_32F) for k, b in tracks.items()}\n band_every = args.banding_every or max(1, int(round(fps)))\n ghost_margin = 96 # half-res px: a fast element moves up to ~190 full-res px per frame\n\n M = dict(idx=[], d=[], d2=[], nchg=[], dr=[], sharp=[], depth={e: [] for e in ('top', 'bottom', 'left', 'right')},\n tv={k: [] for k in tracks}, tr={k: [] for k in tracks}, ghosts=[], crossings=[], band=[], det=[],\n tracks=tracks, sboxes=sboxes, rboxes=rboxes)\n smallq = deque(maxlen=3)\n halfq = deque(maxlen=3)\n prev_gray = None\n first_small = None\n other = None\n if args.against:\n oinfo = probe(args.against)\n if (oinfo['w'], oinfo['h']) != (W, H):\n die('--against file has a different size')\n other = decode(args.against, oinfo, start, count)\n\n for f, rgb in decode(args.video, info, start, count):\n small = cv2.resize(rgb, (SW, SH), interpolation=cv2.INTER_AREA).astype(np.int16)\n M['idx'].append(f)\n if smallq:\n diff = np.abs(small - smallq[-1])\n M['d'].append(float(diff.mean()))\n M['nchg'].append(int((diff.max(axis=2) > 24).sum()))\n M['dr'].append([float(diff[y:y + h, x:x + w].mean()) for (x, y, w, h) in rboxes])\n else:\n M['d'].append(np.nan)\n M['nchg'].append(0)\n M['dr'].append([0.0] * 9)\n smallq.append(small)\n if len(smallq) == 3: # change from f-2 to f, stored at f-1 (out-and-back test)\n M['d2'].append(float(np.abs(smallq[2] - smallq[0]).mean()))\n if first_small is None:\n first_small = small\n gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)\n g16 = gray.astype(np.int16)\n for name, rows in (('top', g16[:8]), ('bottom', g16[-1:-9:-1]), ('left', g16[:, :8].T),\n ('right', g16[:, -1:-9:-1].T)):\n M['depth'][name].append(edge_depth(rows, args.border_delta, args.border_frac))\n\n half = cv2.resize(gray, (hw, hh), interpolation=cv2.INTER_AREA).astype(np.int16)\n halfq.append((f, half))\n if len(halfq) == 3:\n (_, g0), (f1, g1), (_, g2) = halfq\n agree = np.abs(g2 - g0) \u003c 30\n odd = (np.abs(g1 - g0) > args.ghost_delta) & (np.abs(g1 - g2) > args.ghost_delta) & agree\n n_odd = int(odd.sum()) * gscale\n if n_odd > args.ghost_px:\n ys, xs = np.nonzero(odd)\n x0, x1 = max(0, xs.min() - ghost_margin), min(hw, xs.max() + ghost_margin + 1)\n y0, y1 = max(0, ys.min() - ghost_margin), min(hh, ys.max() + ghost_margin + 1)\n moving = int((~agree[y0:y1, x0:x1]).sum()) * gscale\n bx, by = int(xs.min() * 2), int(ys.min() * 2)\n item = dict(frame=f1, px=int(n_odd), motion_px=int(moving),\n bbox=[bx, by, int(xs.max() * 2) - bx + 2, int(ys.max() * 2) - by + 2])\n (M['ghosts'] if n_odd > 1.5 * moving else M['crossings']).append(item)\n\n lap = cv2.Laplacian(half.astype(np.float32), cv2.CV_32F)\n M['sharp'].append([float(lap[y:y + h, x:x + w].var()) for (x, y, w, h) in sboxes])\n\n if tracks:\n gf = gray.astype(np.float32)\n for k, (x, y, w, h) in tracks.items():\n cur = gf[y:y + h, x:x + w]\n prv = prev_gray[y:y + h, x:x + w] if prev_gray is not None else None\n if prv is None or cur.std() \u003c 4 or prv.std() \u003c 4:\n M['tv'][k].append((np.nan, np.nan))\n M['tr'][k].append(0.0)\n continue\n (dx, dy), resp = cv2.phaseCorrelate(prv, cur, hann[k])\n M['tv'][k].append((dx, dy))\n M['tr'][k].append(resp)\n prev_gray = gf\n\n if not args.no_banding and (f - start) % band_every == 0:\n bt = banding_tiles(gray)\n area = float(bt.mean())\n if area > 0 or len(M['band']) \u003c 1:\n img = cv2.cvtColor(stretch(gray), cv2.COLOR_GRAY2BGR)\n for (ty, tx) in zip(*np.nonzero(bt)):\n cv2.rectangle(img, (tx * BAND_T, ty * BAND_T), ((tx + 1) * BAND_T - 1, (ty + 1) * BAND_T - 1),\n (0, 0, 255), 3)\n M['band'].append(dict(frame=f, area=round(area, 4), tiles=int(bt.sum()),\n _img=cv2.resize(img, (480, int(480 * H / W)), interpolation=cv2.INTER_AREA)))\n\n if other is not None:\n try:\n _, orgb = next(other)\n osmall = cv2.resize(orgb, (SW, SH), interpolation=cv2.INTER_AREA).astype(np.int16)\n M['det'].append((f, float(np.abs(small - osmall).mean())))\n except StopIteration:\n other = None\n M['det'].append((f, np.nan))\n if not M['idx']:\n die('decoded 0 frames (bad file, or --from beyond the end)')\n M['first_small'], M['last_small'] = first_small, smallq[-1]\n return M\n\n\n# ------------------------------------------------------------------------------------------\n# pass 2: classify\n# ------------------------------------------------------------------------------------------\ndef evaluate(args, info, M, decl):\n fps = info['fps']\n if args.max_end_hold is None: # the audio's decay: 2 s whatever the frame rate\n args.max_end_hold = int(round(2 * fps)) + 1\n cuts, freezes, ignore, acts, moments, reading, cues_fps = decl\n k_ = fps / cues_fps if cues_fps else 1.0 # cues frames -> video frames\n reading = [(int(round(a * k_)), int(round(b * k_))) for a, b in reading]\n\n def read_run(fa, fb, mean_mad):\n # a run inside one reading window that still changes a little: a held line being read\n return mean_mad >= READ_MAD and any(fa >= x and fb \u003c y for x, y in reading)\n\n idx = np.array(M['idx'])\n f0, n, last = int(idx[0]), len(idx), int(idx[-1])\n dd = np.nan_to_num(np.array(M['d'], dtype=np.float64), nan=0.0)\n nchg = np.array(M['nchg'])\n dr = np.array(M['dr'])\n seams = sorted(a for a, _ in acts.values() if a > 0)\n flags = []\n\n def ignored(f):\n return in_ranges(f, ignore)\n\n def add(check, severity, frame, note, **kw):\n first = frame if isinstance(frame, int) else int(str(frame).split('-')[0])\n item = dict(check=check, severity=severity, frame=frame, t=round(first / fps, 3), note=note)\n if acts:\n item['act'] = act_of(first, acts)\n item.update(kw)\n flags.append(item)\n\n # --- spikes: whole frame, then each of the 9 regions (small pops hide in the global mean)\n def spike_events(x):\n cand = [k for k in range(1, n) if not ignored(f0 + k) and x[k] > args.spike_min * scale\n and x[k] > args.spike_ratio * rolling_median_excl(x, k) + args.spike_add]\n evs = []\n for k in cand:\n b = rolling_median_excl(x, k)\n e = b + 0.35 * (x[k] - b)\n a_, z_ = k, k\n while a_ - 1 >= 1 and x[a_ - 1] > e and k - a_ \u003c 6:\n a_ -= 1\n while z_ + 1 \u003c n and x[z_ + 1] > e and z_ - k \u003c 6:\n z_ += 1\n if evs and a_ \u003c= evs[-1]['z'] + 1:\n ev = evs[-1]\n ev['z'] = max(ev['z'], z_)\n if x[k] > x[ev['k']]:\n ev['k'], ev['base'] = k, b\n else:\n evs.append(dict(a=a_, z=z_, k=k, base=b))\n return evs\n\n scale = 1.0\n events = [dict(ev, region=None, series=dd) for ev in spike_events(dd)]\n scale = args.region_spike_min / args.spike_min\n for rg, box in enumerate(M['rboxes']):\n x = dr[:, rg]\n for ev in spike_events(x):\n if any(g['a'] - 1 \u003c= ev['z'] and ev['a'] \u003c= g['z'] + 1 for g in events if g['region'] is None):\n continue\n events.append(dict(ev, region=rg, series=x))\n events.sort(key=lambda ev: (ev['k'], -1 if ev['region'] is None else ev['region']))\n\n need = sorted({f0 + ev['k'] for ev in events if ev['z'] - ev['a'] + 1 \u003c args.burst_frames}\n | {f0 + ev['k'] - 1 for ev in events if ev['z'] - ev['a'] + 1 \u003c args.burst_frames})\n smalls, spans = {}, []\n for f in need: # re-decode the frames of short events for the flow test, in merged spans\n if spans and f - spans[-1][1] \u003c= 30:\n spans[-1][1] = f\n else:\n spans.append([f, f])\n wanted = set(need)\n for a, b in spans:\n for f, im in decode(args.video, info, a, b - a + 1, size=(SW, SH)):\n if f in wanted:\n smalls[f] = im.copy()\n spikes = []\n for ev in events:\n x, rg = ev['series'], ev['region']\n fa, fz, fk = f0 + ev['a'], f0 + ev['z'], f0 + ev['k']\n L = fz - fa + 1\n it = dict(frame=fk, frames=[fa, fz], mad=round(float(x[ev['k']]), 3), neighbour_median=round(ev['base'], 3))\n where = ''\n if rg is not None:\n bx, by, bw, bh = M['rboxes'][rg]\n k4 = info['w'] / SW\n it['region'] = [int(bx * k4), int(by * k4), int(bw * k4), int(bh * k4)]\n where = f' in region {it[\"region\"]}'\n seam = next((s for s in seams if fa - 1 \u003c= s \u003c= fz + 1), None)\n on_cue = next((m for m in moments if fa - 2 \u003c= m \u003c= fz + 2), None)\n if any(a - 1 \u003c= fz and fa \u003c= b + 1 for a, b in cuts):\n it['class'] = 'cut'\n elif L >= args.burst_frames:\n it['class'] = 'burst'\n else:\n if fk - 1 in smalls and fk in smalls:\n pa, pb = smalls[fk - 1], smalls[fk]\n if rg is not None:\n bx, by, bw, bh = M['rboxes'][rg]\n pa, pb = pa[by:by + bh, bx:bx + bw], pb[by:by + bh, bx:bx + bw]\n res = flow_residual(pa, pb)\n else:\n res = 1.0\n it['flow_residual'] = round(res, 2)\n j = ev['a'] - 1 # d2[j] is the change across the middle frame a (out-and-back test)\n it['out_and_back'] = bool(rg is None and L == 2 and 0 \u003c= j \u003c len(M['d2'])\n and M['d2'][j] \u003c 0.5 * (dd[ev['a']] + dd[ev['z']]))\n busy = rg is not None and rolling_median_excl(dd, ev['k']) > args.region_busy * x[ev['k']]\n if res \u003c args.flow_explained:\n it['class'] = 'motion'\n elif busy:\n it['class'] = 'busy' # the whole frame is changing a lot: part of a larger move\n elif seam is not None:\n it['class'] = 'seam-pop'\n elif on_cue is not None:\n it['class'] = 'hit' if rg is None else 'region-hit'\n else:\n it['class'] = 'pop'\n it['seam'] = seam\n it['cue'] = on_cue\n spikes.append(it)\n what = 'a one-frame image' if it.get('out_and_back') else f'a {L}-frame change'\n size = f'({it[\"mad\"]} vs {ev[\"base\"]:.2f} around it{where})'\n if it['class'] == 'seam-pop':\n add('spike', 'fail', fk, f'{what} at the act seam f{seam} {size} that motion does not explain: a '\n 'property flipping across the cut; match it on both sides, or declare the cut',\n mad=it['mad'], cls=it['class'], region=it.get('region'))\n elif it['class'] == 'pop':\n add('spike', 'fail', fk, f'{what} {size} that motion does not explain and no cue marks: a pop, a '\n 'fade collapsing into 1-2 frames, or an undeclared cut', mad=it['mad'], cls=it['class'],\n region=it.get('region'))\n elif it['class'] == 'hit':\n add('spike', 'warn', fk, f'{what} on the cue at f{on_cue} {size}: fine if it is a designed hit; a '\n 'fade or reveal that finishes in 1-2 frames reads as a pop', mad=it['mad'], cls=it['class'],\n region=it.get('region'))\n\n # --- stalls\n stalls = []\n for k in range(2, n - 1):\n f = f0 + k\n if ignored(f) or in_ranges(f, freezes, 1):\n continue\n if dd[k] \u003c args.stall_max and dd[k - 1] > args.stall_moving and dd[k + 1] > args.stall_moving:\n seam = next((s for s in seams if abs(s - f) \u003c= 1), None)\n stalls.append(dict(frame=f, mad=round(float(dd[k]), 3), before=round(float(dd[k - 1]), 2),\n after=round(float(dd[k + 1]), 2), seam=seam))\n add('stall', 'fail', f, f'frame {f} repeats frame {f - 1} inside motion (stop-hold-go)'\n + (f' at the seam f{seam}' if seam else '') + '; show the rest pose once or keep the move alive')\n\n # --- holds\n still = dd \u003c args.hold_mad\n still[0] = False\n holds = []\n for a, b in runs(still):\n fa, fb = f0 + a - 1, f0 + b\n length = fb - fa + 1\n if length \u003c= args.max_hold:\n continue\n frozen = float(np.percentile(nchg[a:b + 1], 90)) \u003c args.frozen_px # a stray moving frame is not life\n declared = any(fa >= x - 2 and fb \u003c= y + 2 for x, y in freezes)\n at_end = fb >= info['frames'] - 2\n mean_mad = float(dd[a:b + 1].mean())\n kind = ('declared' if declared else 'end' if at_end else 'read' if read_run(fa, fb, mean_mad)\n else ('frozen' if frozen else 'quiet'))\n holds.append({'from': fa, 'to': fb, 'frames': length, 'seconds': round(length / fps, 2), 'kind': kind,\n 'mean_mad': round(mean_mad, 3), 'max_changed_px': int(nchg[a:b + 1].max())})\n if kind in ('declared', 'read') or ignored(fa) or ignored(fb):\n continue\n if kind == 'end':\n if length > args.max_end_hold:\n add('hold', 'fail', f'{fa}-{fb}', f'static end tail of {length} f ({length / fps:.1f} s); keep it \u003c= '\n f'{args.max_end_hold} f (the audio decay) or give it a visible build (a slow push or drift)')\n elif kind == 'frozen':\n add('hold', 'fail', f'{fa}-{fb}', f'{length} f ({length / fps:.1f} s) frozen: reads as a stalled player; '\n 'add a build (push, drift, breath) or declare it with --freeze-ok')\n else:\n add('hold', 'warn', f'{fa}-{fb}', f'{length} f ({length / fps:.1f} s) where only small elements move '\n f'(mean change {holds[-1][\"mean_mad\"]}): the frame reads locked-off; add a push or drift')\n\n # --- quiet stretches that are not already holds\n quiet = []\n win = args.quiet_window\n if n > win:\n cs = np.concatenate([[0], np.cumsum(dd)])\n mean = (cs[win:] - cs[:-win]) / win # mean[k] covers dd[k..k+win-1]\n for a, b in runs(mean \u003c args.quiet_mad):\n fa, fb = f0 + a, f0 + b + win - 1\n if any(h['from'] \u003c= fb and fa \u003c= h['to'] for h in holds):\n continue\n if any(fa >= x - 2 and fb \u003c= y + 2 for x, y in freezes) or ignored(fa):\n continue\n q = {'from': fa, 'to': fb, 'mean_mad': round(float(dd[a:b + win].mean()), 3)}\n q['kind'] = 'read' if read_run(fa, fb, q['mean_mad']) else 'quiet'\n quiet.append(q)\n if q['kind'] == 'quiet':\n add('quiet', 'warn', f'{fa}-{fb}', f'{fb - fa + 1} f of near-stillness (mean change {q[\"mean_mad\"]}); '\n 'fine for a read, otherwise add drift or a push')\n\n # --- hook: the opening carries at least the film's typical energy (a muted feed decides in second one)\n hook = None\n hw = int(round(2 * fps))\n if n \u003c= 3 * hw: # a sting or a short cut: judge its first second against its typical second\n hw = int(round(fps))\n if f0 == 0 and last + 1 >= info['frames'] - 2 and n > 3 * hw and not getattr(args, 'loop', False):\n cs = np.concatenate([[0], np.cumsum(dd)])\n typical = float(np.median((cs[hw:] - cs[:-hw]) / hw))\n opening = float(dd[1:hw].mean())\n hook = dict(first_2s=round(opening, 3), typical_2s=round(typical, 3))\n if opening \u003c max(0.5 if hw > fps * 1.5 else 0.0, 0.5 * typical) and not ignored(0):\n add('hook', 'warn', f'0-{hw - 1}', f'the first {hw / fps:.0f} s move {opening:.2f} against a typical {hw / fps:.0f} s of {typical:.2f}: '\n 'a still opening loses a muted feed; start inside the action (the device already moving, the first '\n 'change by 0.5 s) and let the first line land over motion')\n\n # --- a still opening: the first visible change comes at a median 0.1 s (all by 0.43 s) in top-tier\n # launch films, and a still title held 2.7 s before any motion reads as a slow hook. \"Still\" is\n # measured on the film's own scale (a fine-line sting moves less than a product film): the first\n # half second whose mean change passes 0.3x the median change of the film's moving frames.\n first_change = None\n if f0 == 0 and not getattr(args, 'loop', False) and n > fps:\n moving = dd[1:][dd[1:] > args.hold_mad]\n thr = max(0.03, 0.3 * float(np.median(moving))) if len(moving) else 0.03\n w = max(1, int(round(fps / 2)))\n cs = np.concatenate([[0], np.cumsum(dd)])\n over = np.nonzero((cs[w:] - cs[:-w]) / w > thr)[0]\n first_change = round(float(over[0]) / fps, 2) if len(over) else round(n / fps, 2)\n hook = {**(hook or {}), 'first_change_s': first_change}\n if first_change > 1.0 and not ignored(0) and not any(x \u003c= 2 for x, _ in freezes):\n add('hook', 'fail' if first_change > 2.5 else 'warn', f'0-{int(first_change * fps)}',\n f'the first visible change comes at {first_change:.1f} s: it belongs inside 0.5 s, and the first '\n 'hard change (a cut, a reveal, a surge) by about 1.3 s. Start inside the action, put a push or drift '\n 'under the first read, or declare a designed still with --freeze-ok')\n\n # --- ghosts\n for g in M['ghosts']:\n if ignored(g['frame']) or in_ranges(g['frame'], cuts, 1):\n continue\n add('ghost', 'fail', g['frame'], f'one-frame image on {g[\"px\"]} px that neither neighbour has and nothing '\n 'near it moves (stale layer, compositor race, one-frame flash): inspect with grab.sh', bbox=g['bbox'])\n\n # --- border slivers: constant depth for >= N frames\n borders = []\n for edge, dep in M['depth'].items():\n dep = np.array(dep)\n k = 0\n while k \u003c n:\n if dep[k] == 0:\n k += 1\n continue\n j = k\n while j + 1 \u003c n and dep[j + 1] == dep[k]:\n j += 1\n if j - k + 1 >= args.sliver_frames and not ignored(f0 + k):\n borders.append(dict(edge=edge, depth_px=int(dep[k]), frames=[f0 + k, f0 + j]))\n add('border', 'fail', f'{f0 + k}-{f0 + j}', f'{edge} edge: the outer {dep[k]} px differ from the '\n f'pixels inside for {j - k + 1} frames (a sliver or gap); overscan moving layers, clamp the camera')\n k = j + 1\n\n # --- judder (whole-pixel stairs)\n judder = []\n for key, box in M['tracks'].items():\n v = np.array(M['tv'][key], dtype=np.float64)\n r = np.array(M['tr'][key], dtype=np.float64)\n for w in stairs(v, r, f0, args.judder_window, args.judder_resp):\n if ignored(w['from']) or ignored(w['to']):\n continue\n w['track'], w['box'] = key, list(box)\n judder.append(w)\n add('judder', 'warn', f'{w[\"from\"]}-{w[\"to\"]}', f'a slow move ({w[\"speed\"]} px/f) in {key} {list(box)} '\n f'advances in whole-pixel stairs ({w[\"jumps\"]} jumps): put all translation inside one transform '\n 'and animate no left/top', track=key)\n\n # --- sharpness steps in otherwise calm regions\n S = np.array(M['sharp'])\n sharp_steps = []\n spiky = [(s_['frames'][0] - 1, s_['frames'][1] + 1) for s_ in spikes if s_['class'] != 'motion']\n for k in range(2, n - 1):\n f = f0 + k\n if ignored(f) or in_ranges(f, cuts, 1) or in_ranges(f, spiky):\n continue # a spike there is already reported\n for rg in range(S.shape[1]):\n a_, b_ = S[k - 1, rg], S[k, rg]\n if max(a_, b_) \u003c args.sharp_min:\n continue\n ratio = max(a_, b_) / max(min(a_, b_), 1e-3)\n if ratio \u003c args.sharp_ratio or dr[k - 1, rg] > 1.0 or dr[k + 1, rg] > 1.0:\n continue\n p = max(S[k - 1, rg], S[k - 2, rg]) / max(min(S[k - 1, rg], S[k - 2, rg]), 1e-3)\n q = max(S[k, rg], S[k + 1, rg]) / max(min(S[k, rg], S[k + 1, rg]), 1e-3)\n if p \u003c 1.5 and q \u003c 1.5:\n x, y, w, h = M['sboxes'][rg]\n reg = [x * 2, y * 2, w * 2, h * 2]\n sharp_steps.append(dict(frame=f, region=reg, before=round(float(a_), 1), after=round(float(b_), 1)))\n add('sharpness', 'warn', f, f'sharpness {a_:.0f} -> {b_:.0f} in one frame in region {reg} while it '\n 'is otherwise still: a stepped blur ramp; cross-fade a sharp and a blurred copy (RackFocus)')\n break\n\n # --- banding\n band = [{k: v for k, v in s.items() if k != '_img'} for s in M['band']]\n for s in band:\n s['flagged'] = s['area'] > args.band_area\n if s['flagged'] and not ignored(s['frame']):\n add('banding', 'warn', s['frame'], f'{s[\"area\"] * 100:.1f}% of the frame ({s[\"tiles\"]} tiles) is smooth '\n 'gradient in flat 8-bit plateaus: visible bands; use dithered PNG backdrops (red boxes in the sheet)')\n band_sheet = None\n if M['band'] and args.banding_sheet:\n picks = sorted(M['band'], key=lambda s: -s['area'])[:8]\n rest = [s for s in M['band'] if s not in picks]\n picks += rest[:: max(1, len(rest) // max(1, 12 - len(picks)))][:12 - len(picks)]\n picks.sort(key=lambda s: s['frame'])\n ims = [label(s['_img'].copy(), f'f{s[\"frame\"]} {s[\"frame\"] / fps:.2f}s',\n f'x12 banded {s[\"area\"] * 100:.1f}%') for s in picks]\n os.makedirs(os.path.dirname(os.path.abspath(args.banding_sheet)), exist_ok=True)\n cv2.imwrite(args.banding_sheet, tile(ims, 4))\n band_sheet = args.banding_sheet\n\n # --- compression smear on flat fields\n smear = [{k: v for k, v in x.items() if k != '_img'} for x in M.get('smear', [])]\n over = [x for x in smear if x['area'] > args.smear_area and not ignored(x['frame'])]\n for x in smear:\n x['flagged'] = x in over\n if over:\n sev = 'fail' if len(over) >= 2 else 'warn'\n worst = max(over, key=lambda x: x['area'])\n span = f'{over[0][\"frame\"]}-{over[-1][\"frame\"]}' if len(over) > 1 else over[0]['frame']\n add('smear', sev, span, f'compression smear on the flat field (level {worst[\"level\"]}) in {len(over)} sampled '\n f'frames, up to {worst[\"area\"] * 100:.0f}% of the frame at f{worst[\"frame\"]}: blocks of fine 1-level texture '\n 'next to clean ones (smear sheet). Blurred master: re-run the current scripts/accumulate.py, which rounds '\n 'flat fields exactly; otherwise encode at a lower CRF or 10-bit', frames=[x['frame'] for x in over][:50])\n smear_sheet = None\n if M.get('smear') and args.smear_sheet:\n picks = sorted(M['smear'], key=lambda x: -x['area'])[:4]\n rest = [x for x in M['smear'] if x not in picks]\n picks += rest[:: max(1, len(rest) // 4)][:8 - len(picks)]\n picks.sort(key=lambda x: x['frame'])\n ims = [label(x['_img'].copy(), f'f{x[\"frame\"]} {x[\"frame\"] / fps:.2f}s',\n f'x25 smear {x[\"area\"] * 100:.0f}% (field {x[\"field\"] * 100:.0f}%)') for x in picks]\n os.makedirs(os.path.dirname(os.path.abspath(args.smear_sheet)), exist_ok=True)\n cv2.imwrite(args.smear_sheet, tile(ims, 4))\n smear_sheet = args.smear_sheet\n\n # --- loop seam\n loop = None\n if args.loop:\n seam = float(np.abs(M['last_small'] - M['first_small']).mean())\n ends = np.concatenate([dd[1:9], dd[-8:]])\n local = float(np.median(ends)) if len(ends) else 0.0\n limit = max(args.seam_abs, args.seam_ratio * local)\n loop = {'seam_mad': round(seam, 3), 'median_step_at_ends': round(local, 3), 'limit': round(limit, 3),\n 'pass': seam \u003c= limit}\n if not loop['pass']:\n add('loop_seam', 'fail', last, f'loop seam change {seam:.2f} > {limit:.2f}: the last frame does not flow '\n 'into frame 0 (match state and velocity; the loop is whole bars long)')\n\n # --- determinism\n det = None\n if args.against:\n vals = np.array([x for _, x in M['det']], dtype=np.float64)\n med = float(np.nanmedian(vals)) if len(vals) and not np.isnan(vals).all() else 0.0\n lim = max(args.det_min, args.det_ratio * med)\n bad = [dict(frame=f, mad=round(x, 3)) for f, x in M['det'] if not np.isnan(x) and x > lim]\n det = dict(against=args.against, median=round(med, 4), limit=round(lim, 3), frames=bad[:200])\n for b in bad[:50]:\n add('determinism', 'fail', b['frame'], f'the two renders differ here ({b[\"mad\"]}): nondeterministic '\n 'frame; look for will-change on a blurred layer, Math.random, Date.now')\n if np.isnan(vals).any() or probe(args.against)['frames'] != info['frames']:\n add('determinism', 'fail', last, 'the two renders have different frame counts')\n\n # --- energy summary for pacing reads\n step = max(1, int(round(fps)))\n per_sec = [round(float(dd[max(1, s):s + step].mean()), 2) for s in range(0, n, step) if len(dd[max(1, s):s + step])]\n per_act = {}\n for name, (a, b) in acts.items():\n lo, hi = max(1, a - f0), min(n, b - f0)\n if hi > lo:\n seg = dd[lo:hi]\n per_act[name] = dict(mean=round(float(seg.mean()), 2), p90=round(float(np.percentile(seg, 90)), 2),\n still_frac=round(float((seg \u003c args.hold_mad).mean()), 2))\n seam_table = [dict(frame=s, mad=round(float(dd[s - f0]), 3), neighbour_median=round(rolling_median_excl(dd, s - f0), 3))\n for s in seams if 1 \u003c= s - f0 \u003c n]\n\n sev = {'fail': 0, 'warn': 1}\n flags.sort(key=lambda x: (sev[x['severity']], int(str(x['frame']).split('-')[0])))\n fails = sum(1 for x in flags if x['severity'] == 'fail')\n warns = len(flags) - fails\n counts = {}\n for s in spikes:\n counts[s['class']] = counts.get(s['class'], 0) + 1\n return {\n 'file': args.video, 'fps': round(fps, 3), 'size': [info['w'], info['h']], 'frames': n, 'range': [f0, last],\n 'pass': fails == 0, 'fails': fails, 'warnings': warns,\n 'declared': dict(cuts=[list(x) for x in cuts], freezes=[list(x) for x in freezes],\n ignore=[list(x) for x in ignore], cue_moments=len(moments), reading=[list(x) for x in reading]),\n 'pace': args.pace,\n 'thresholds': {k: getattr(args, k) for k in (\n 'spike_min', 'region_spike_min', 'region_busy', 'spike_ratio', 'spike_add', 'burst_frames', 'flow_explained', 'stall_max', 'stall_moving',\n 'hold_mad', 'max_hold', 'frozen_px', 'max_end_hold', 'quiet_window', 'quiet_mad', 'ghost_delta',\n 'ghost_px', 'border_delta', 'border_frac', 'sliver_frames', 'judder_window', 'judder_resp',\n 'sharp_ratio', 'sharp_min', 'band_area', 'smear_area', 'seam_abs', 'seam_ratio', 'det_min', 'det_ratio')},\n 'stats': dict(mad_median=round(float(np.median(dd[1:])), 3) if n > 1 else 0,\n mad_p90=round(float(np.percentile(dd[1:], 90)), 3) if n > 1 else 0,\n mad_max=round(float(dd.max()), 3), mad_max_frame=int(f0 + int(np.argmax(dd))),\n spike_classes=counts, fast_crossings=len(M['crossings']),\n energy_per_second=per_sec, energy_per_act=per_act),\n 'flags': flags,\n 'checks': dict(spikes=spikes, stalls=stalls, holds=holds, quiet=quiet, hook=hook, ghosts=M['ghosts'],\n crossings=M['crossings'][:100], borders=borders, judder=judder, sharpness=sharp_steps,\n banding=band, banding_sheet=band_sheet, smear=smear, smear_sheet=smear_sheet,\n seams=seam_table, loop=loop, determinism=det),\n }\n\n\ndef main():\n ap = argparse.ArgumentParser(description=__doc__.split('\\n')[0],\n formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)\n ap.add_argument('video')\n ap.add_argument('--json', help='write the report here (e.g. out/qa/forensics.json)')\n ap.add_argument('--cues', help='out/cues.json: act seams, cue moments, a \"qa\" block, events of kind \"cut\"')\n ap.add_argument('--declare', help='JSON with cuts / freezes / ignore')\n ap.add_argument('--cuts', help='declared hard cuts or flash transitions, e.g. 600,836-840')\n ap.add_argument('--freeze-ok', help='declared freezes/holds, e.g. 345-359')\n ap.add_argument('--ignore', help='frames to skip entirely, e.g. 10-12')\n ap.add_argument('--from', dest='from_frame', type=int, help='first frame to analyse')\n ap.add_argument('--to', dest='to_frame', type=int, help='last frame to analyse (inclusive)')\n ap.add_argument('--loop', action='store_true', help='check the loop seam (last frame -> frame 0)')\n ap.add_argument('--against', help='a second render of the same thing; report frames that differ')\n ap.add_argument('--track', action='append', help='x,y,w,h region to phase-track for judder (repeatable)')\n ap.add_argument('--no-judder', action='store_true', help='skip the automatic 3x3 judder patches')\n ap.add_argument('--no-banding', action='store_true')\n ap.add_argument('--banding-sheet', help='x12 contrast-stretch sheet PNG (default: banding.png next to --json)')\n ap.add_argument('--banding-every', type=int, help='sample one frame per N for banding (default: fps)')\n ap.add_argument('--no-smear', action='store_true', help='skip the flat-field compression smear check')\n ap.add_argument('--smear-sheet', help='x25 stretch sheet of the smear samples (default: smear.png next to --json)')\n ap.add_argument('--smear-every', type=int, help='sample one frame per N for smear (default: fps / 2)')\n ap.add_argument('--curves', help='also write per-frame MAD and sharpness curves to this JSON')\n ap.add_argument('--pace', choices=sorted(PACE_LIMITS), help=\"hold and quiet limits (default: the cues file's pace, else crisp)\")\n g = ap.add_argument_group('thresholds (defaults are proven starting points; see references/review-loop.md)')\n g.add_argument('--spike-min', type=float, default=1.0)\n g.add_argument('--spike-ratio', type=float, default=2.5)\n g.add_argument('--spike-add', type=float, default=0.3)\n g.add_argument('--region-spike-min', type=float, default=1.5, help='spike floor inside one of 9 regions')\n g.add_argument('--region-busy', type=float, default=0.15,\n help='a region spike is part of a larger move when the whole-frame change exceeds this share')\n g.add_argument('--burst-frames', type=int, default=3, help='a change spread over this many frames is a burst')\n g.add_argument('--flow-explained', type=float, default=0.5, help='flow residual below this = motion')\n g.add_argument('--stall-max', type=float, default=0.05)\n g.add_argument('--stall-moving', type=float, default=0.8)\n g.add_argument('--hold-mad', type=float, default=0.1)\n g.add_argument('--max-hold', type=int, default=None, help='frames (default: the pace\\'s, calm 72, crisp 48)')\n g.add_argument('--frozen-px', type=int, default=16, help='a hold is frozen if fewer px change by >24 levels')\n g.add_argument('--max-end-hold', type=int, default=None, help='frames of static tail allowed at the end (default: 2 s at the film\\'s rate, plus 1)')\n g.add_argument('--quiet-window', type=int, default=60)\n g.add_argument('--quiet-mad', type=float, default=None, help='(default: the pace\\'s, calm 0.10, crisp 0.15)')\n g.add_argument('--ghost-delta', type=int, default=80)\n g.add_argument('--ghost-px', type=int, default=2000, help='full-res pixels')\n g.add_argument('--border-delta', type=int, default=40)\n g.add_argument('--border-frac', type=float, default=0.25)\n g.add_argument('--sliver-frames', type=int, default=3)\n g.add_argument('--judder-window', type=int, default=24)\n g.add_argument('--judder-resp', type=float, default=0.7, help='phase-correlation response needed')\n g.add_argument('--sharp-ratio', type=float, default=3.0)\n g.add_argument('--sharp-min', type=float, default=30.0)\n g.add_argument('--band-area', type=float, default=0.03)\n g.add_argument('--smear-area', type=float, default=0.2,\n help='smeared share of the frame that flags a sample (Tessel peaks at 0.15; the smeared sting at 0.29-0.42)')\n g.add_argument('--seam-abs', type=float, default=0.4)\n g.add_argument('--seam-ratio', type=float, default=1.5)\n g.add_argument('--det-min', type=float, default=0.3)\n g.add_argument('--det-ratio', type=float, default=5.0)\n args = ap.parse_args()\n if args.banding_sheet is None and args.json and not args.no_banding:\n args.banding_sheet = os.path.join(os.path.dirname(os.path.abspath(args.json)), 'banding.png')\n\n if args.smear_sheet is None and args.json and not args.no_smear:\n args.smear_sheet = os.path.join(os.path.dirname(os.path.abspath(args.json)), 'smear.png')\n\n info = probe(args.video)\n decl = load_declarations(args)\n if not args.pace:\n args.pace = (load_cues(args.cues)['pace'] if args.cues else None) or 'crisp'\n for k, v in PACE_LIMITS[args.pace].items():\n if getattr(args, k) is None:\n setattr(args, k, v)\n M = analyse(args, info)\n M['smear'] = [] if args.no_smear else smear_scan(args, info)\n res = evaluate(args, info, M, decl)\n\n if args.curves:\n os.makedirs(os.path.dirname(os.path.abspath(args.curves)), exist_ok=True)\n with open(args.curves, 'w') as fh:\n json.dump(dict(first=int(M['idx'][0]), fps=res['fps'],\n mad=[round(float(x), 3) for x in np.nan_to_num(np.array(M['d'], dtype=float))],\n sharpness=[[round(float(v), 1) for v in row] for row in M['sharp']]), fh)\n if args.json:\n os.makedirs(os.path.dirname(os.path.abspath(args.json)), exist_ok=True)\n with open(args.json, 'w') as fh:\n json.dump(res, fh, indent=1, default=lambda o: o.item() if hasattr(o, 'item') else str(o))\n\n print(f'forensics: {args.video} {res[\"frames\"]} frames @ {res[\"fps\"]} fps '\n f'{\"PASS\" if res[\"pass\"] else \"FAIL\"} ({res[\"fails\"]} fail, {res[\"warnings\"]} warn)')\n for x in res['flags'][:30]:\n act = f' [{x[\"act\"]}]' if x.get('act') else ''\n print(f' {x[\"severity\"]:4s} {x[\"check\"]:9s} f{x[\"frame\"]}{act}: {x[\"note\"]}')\n if len(res['flags']) > 30:\n print(f' ... {len(res[\"flags\"]) - 30} more in the JSON')\n if args.json:\n print(f' report: {args.json}')\n sys.exit(0 if res['pass'] else 1)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":63771,"content_sha256":"d5c199eb1072b75e2c34c5363a7b1c6e1823e15e98867c6b1b7aebe7b3776b76"},{"filename":"scripts/grab.sh","content":"#!/usr/bin/env bash\n# grab.sh - exact frame grabs from a video, by frame number (frame = t * fps, 0-based).\n#\n# Usage:\n# bash scripts/grab.sh out/film.mp4 405 1796 1790-1800 [--out out/qa/frames] [--width 960] [--crop w:h:x:y]\n#\n# Each frame f is sought with -ss (f - 0.5)/fps, so ffmpeg's accurate seek returns frame f itself\n# (seeking to f/fps lands a frame early or late on some files), and -fps_mode passthrough stops\n# ffmpeg from duplicating the first frame of a range. A range a-b is decoded in one pass.\n# Files are written as \u003cout>/f\u003cframe, 5 digits>.png (full resolution unless --width/--crop).\n# Prints one written path per line. Exit: 0 ok, 1 a grab failed, 2 usage error.\nset -euo pipefail\n\nusage() { sed -n '2,11p' \"$0\" | sed 's/^# \\{0,1\\}//'; exit \"${1:-2}\"; }\ncase \"${1:-}\" in -h|--help) usage 0 ;; esac\n[ $# -lt 2 ] && usage\n\nVIDEO=$1; shift\n[ -f \"$VIDEO\" ] || { echo \"grab.sh: no such file: $VIDEO\" >&2; exit 2; }\nOUT=out/qa/frames; WIDTH=\"\"; CROP=\"\"; FRAMES=()\nwhile [ $# -gt 0 ]; do\n case \"$1\" in\n --out) OUT=$2; shift 2 ;;\n --width) WIDTH=$2; shift 2 ;;\n --crop) CROP=$2; shift 2 ;;\n -h|--help) usage 0 ;;\n *) FRAMES+=(\"$1\"); shift ;;\n esac\ndone\n[ ${#FRAMES[@]} -gt 0 ] || usage\nmkdir -p \"$OUT\"\n\nRATE=$(ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 \"$VIDEO\")\n[ -n \"$RATE\" ] || { echo \"grab.sh: $VIDEO has no video stream\" >&2; exit 2; }\nNUM=${RATE%/*}; DEN=${RATE#*/}; [ \"$DEN\" = \"$RATE\" ] && DEN=1\n\nVF=()\nFILTER=\"\"\n[ -n \"$CROP\" ] && FILTER=\"crop=$CROP\"\n[ -n \"$WIDTH\" ] && FILTER=\"${FILTER:+$FILTER,}scale=$WIDTH:-2:flags=lanczos\"\n[ -n \"$FILTER\" ] && VF=(-vf \"$FILTER\")\n\nstatus=0\nfor spec in ${FRAMES[@]+\"${FRAMES[@]}\"}; do\n if [[ \"$spec\" =~ ^([0-9]+)-([0-9]+)$ ]]; then A=${BASH_REMATCH[1]}; B=${BASH_REMATCH[2]}\n elif [[ \"$spec\" =~ ^[0-9]+$ ]]; then A=$spec; B=$spec\n else echo \"grab.sh: not a frame or range: $spec\" >&2; exit 2; fi\n [ \"$B\" -ge \"$A\" ] || { echo \"grab.sh: empty range $spec\" >&2; exit 2; }\n N=$((B - A + 1))\n for ((f = A; f \u003c= B; f++)); do rm -f \"$(printf '%s/f%05d.png' \"$OUT\" \"$f\")\"; done\n SS=$(awk -v f=\"$A\" -v n=\"$NUM\" -v d=\"$DEN\" 'BEGIN { t = (f - 0.5) * d / n; if (t \u003c 0) t = 0; printf \"%.6f\", t }')\n if ffmpeg -v error -nostdin -y -ss \"$SS\" -i \"$VIDEO\" -map 0:v:0 -frames:v \"$N\" ${VF[@]+\"${VF[@]}\"} \\\n -fps_mode passthrough -start_number \"$A\" \"$OUT/f%05d.png\"; then\n for ((f = A; f \u003c= B; f++)); do\n p=$(printf '%s/f%05d.png' \"$OUT\" \"$f\")\n if [ -f \"$p\" ]; then echo \"$p\"; else echo \"grab.sh: frame $f is past the end\" >&2; status=1; fi\n done\n else\n echo \"grab.sh: ffmpeg failed on $spec\" >&2; status=1\n fi\ndone\nexit $status\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":2661,"content_sha256":"bcb4c59938ca7a6633a7d9b0b680c1e862e42bbcde548c1d76ee52b0d79b557d"},{"filename":"scripts/grid-check.ts","content":"/**\n * grid-check.ts: checks a film's timeline against the timing rules before anything renders.\n *\n * Imports the project's src/timeline.ts (FPS, BPM, TOTAL, ACT, CUE, COPY; optional BEAT, BAR, TAIL,\n * PACE_NAME, PACE, READ), or a HyperFrames project's timeline.js (its FILM object; PACE_NAME and\n * PACE from the timeline or the motion.js beside it), and checks, with the 60 fps numbers scaled\n * to the film's fps:\n * beat-whole one beat is a whole number of frames (60*FPS/BPM)\n * acts acts are contiguous from 0 to TOTAL with positive lengths\n * acts-on-bar every act starts on a bar line (warning)\n * cue-grid every CUE is a frame in [0, TOTAL) on the 16th grid, or its line carries a\n * trailing `// offgrid: \u003creason>` comment (or an entry in an exported OFFGRID map)\n * copy every COPY entry has in \u003c= resolved \u003c= out inside the film\n * text-hold resolved text holds >= max(holdMin, holdBase + holdPerWord * words) before it\n * leaves (calm: 60 f, or 42 f + 8 f/word; crisp: 36 f + 6 f/word); a build chain's\n * line holds that long for all its words after its last fragment resolves\n * fragment words per COPY entry: a warning above maxNewWords (calm 4, crisp 5), an error\n * above maxNewWords + 2 when calm (a full sentence arriving at once) or above\n * 10 when crisp; kind: 'ui' entries are exempt\n * copy-lines \u003c= 2 lines per entry (warning)\n * copy-concurrent film words on screen at once (in \u003c= f \u003c out + exitDur: a leaving fragment is\n * still seen until its exit is over): an error above maxVisibleBuild (calm 8), or\n * above maxVisibleReplace (calm 5) when every entry on screen is a replace-mode\n * fragment; a warning when film and ui words together pass 14\n * read-first two film entries that are not one chain: the later one arrives only after the\n * earlier one, still on screen, has resolved and held its read (an error when\n * calm, a warning when crisp): two lines arriving together are a sentence dump\n * reveal-rate an entry of 3+ words revealed faster than 8 f per word, (resolved - in) /\n * (words - 1): a sentence dumped at once (warning)\n * chain-gap inside a chain (COPY entries sharing `chain`): each fragment arrives only after\n * the previous one has resolved and held its read (error), with a picture beat of\n * gapMin..gapMax frames (calm 30-90 f) after that read (shorter: an error when\n * calm, a warning when crisp; longer: a warning); in replace mode the previous\n * fragment's exit (exitDur: calm 24 f, crisp 16 f) is over >= 6 f before the next\n * arrives (error); a chain keeps one mode and a build chain clears at one `out`\n * chain-beat the picture beat between two fragments really happens: at least one CUE (or,\n * with --cues, a sound event other than a `text` sound) in [read, next.in) (an\n * error when calm, a warning when crisp)\n * read-quiet calm: no CUE starts inside a reading window (READ) other than a text's own\n * frames: what was just said is read in the quiet (warning)\n * word-rate film words resolving in any 5 s window \u003c= 5 * maxWordsPerSec (calm 6), and the\n * body's average \u003c= avgWordsPerSec (calm 0.9 w/s) (warnings)\n * text-share film copy on screen on \u003c= textShare of the body's frames (calm 65%; a warning,\n * an error when calm and more than 10 points over), and every 10 s of body (all\n * of it, if shorter) holds a text-free run >= freeRun frames (calm 90 f): the\n * picture gets its turns (warning)\n * word-total on-screen words \u003c= --max-words (default: the pace's maxWords, calm 30, crisp 35);\n * entries with kind: 'ui' (terminal commands, code, URLs, app labels the product\n * shows) are counted apart, not budgeted\n * event-gap no stretch without an event (cue, copy change, act change, or a cues.json event\n * with --cues) longer than --max-gap frames (default: the pace's maxGap, calm 72 f,\n * crisp 48 f), tail excluded. A reading window counts as alive: a hold being read\n * needs a slow drift, not a tick planted in it. The windows are the timeline's\n * READ (src/lib/pace.ts readingWindows(): from `resolved` for at most readMax, calm\n * 150 f; an earlier fragment of a chain only for its own hold, so a chain's picture\n * beat is not a read; a build line once more after its last fragment), else a\n * cues.json copy block's `read`, else the same rule computed here. One designed\n * near-still hold may be declared with `export const HOLD = {from, to}` (60-96 f\n * at 60 fps, on the key claim under a music dropout), and gaps inside it pass\n * tail >= 1 s after the last cue so the audio can decay (warning)\n * whole-bars the body (TOTAL - TAIL) is whole bars; loops need it (warning)\n *\n * The pace: --pace calm|crisp, else the timeline's PACE_NAME, else crisp (a timeline written before\n * paces existed). The numbers come from the timeline's exported PACE (src/lib/pace.ts) when it has\n * one, so a retuned token moves the checks with the picture; otherwise from the copy below.\n *\n * Usage:\n * npx tsx scripts/grid-check.ts [src/timeline.ts | timeline.js] [--pace calm|crisp] [--strict]\n * [--json] [--max-words N] [--max-gap N] [--cues out/cues.json]\n * Without a path: src/timeline.ts, else timeline.js. Node 22.18+ runs it without tsx too, when\n * the timeline has no extensionless imports (a HyperFrames timeline.js):\n * node scripts/grid-check.ts timeline.js\n * --strict warnings fail too. --json machine-readable report on stdout.\n * Exit codes: 0 pass, 1 a check failed (or a warning with --strict), 2 bad arguments / import error.\n */\nimport { existsSync, readFileSync } from 'node:fs';\nimport { createRequire } from 'node:module';\nimport { dirname, join, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { createContext, runInContext } from 'node:vm';\n\ntype Act = { from: number; dur: number };\ntype Copy = { id: string; text: string; in: number; resolved: number; out?: number | null; kind?: string; chain?: string; mode?: string };\ntype Finding = { check: string; severity: 'error' | 'warn'; message: string };\ntype Pace = {\n holdBase: number; holdPerWord: number; holdMin: number; readMax: number; gapMin: number; gapMax: number;\n maxNewWords: number; maxVisibleBuild: number; maxVisibleReplace: number; maxWordsPerSec: number;\n avgWordsPerSec: number; maxWords: number; textShare: number; freeRun: number; maxGap: number; exitDur: number;\n};\ntype Windows = Record\u003cstring, [number, number][]>;\n\n// The check numbers of src/lib/pace.ts (60 fps frames), for timelines that do not export PACE.\nconst BUILTIN: Record\u003c'calm' | 'crisp', Pace> = {\n calm: { holdBase: 42, holdPerWord: 8, holdMin: 60, readMax: 150, gapMin: 30, gapMax: 90, maxNewWords: 4, maxVisibleBuild: 8, maxVisibleReplace: 5, maxWordsPerSec: 1.2, avgWordsPerSec: 0.9, maxWords: 30, textShare: 0.65, freeRun: 90, maxGap: 72, exitDur: 24 },\n crisp: { holdBase: 36, holdPerWord: 6, holdMin: 42, readMax: 120, gapMin: 18, gapMax: 90, maxNewWords: 5, maxVisibleBuild: 10, maxVisibleReplace: 10, maxWordsPerSec: 2.0, avgWordsPerSec: 1.2, maxWords: 35, textShare: 0.8, freeRun: 60, maxGap: 48, exitDur: 16 },\n};\n\nconst argv = process.argv.slice(2);\nconst has = (f: string) => argv.includes(f);\nconst val = (f: string) => {\n const i = argv.indexOf(f);\n return i >= 0 ? argv[i + 1] : undefined;\n};\nif (has('-h') || has('--help')) {\n console.log(readFileSync(new URL(import.meta.url), 'utf8').split('*/')[0].replace(/^\\/\\*\\*?/, '').replace(/^ \\* ?/gm, ''));\n process.exit(0);\n}\nconst valued = new Set(['--max-words', '--max-gap', '--cues', '--pace']);\nconst positional = argv.filter((a, i) => !a.startsWith('--') && !valued.has(argv[i - 1]));\nconst tlPath = resolve(positional[0] ?? (!existsSync('src/timeline.ts') && existsSync('timeline.js') ? 'timeline.js' : 'src/timeline.ts'));\nconst json = has('--json');\nconst strict = has('--strict');\n\nconst die = (msg: string): never => {\n if (json) console.log(JSON.stringify({ tool: 'grid-check', pass: false, fatal: msg }));\n else console.error(`grid-check: ${msg}`);\n process.exit(2);\n};\n\nconst wordsOf = (text: unknown) => String(text ?? '').split(/\\s+/).filter(Boolean).length;\n\n/**\n * src/lib/pace.ts readingWindows(), for a timeline that exports no READ (and no cues.json `read`):\n * the same rule, so every check reads text the same way.\n */\nconst readingWindows = (copy: Copy[], P: Pace, fps: number, total: number): Windows => {\n const s = fps / 60;\n const cap = Math.round(P.readMax * s);\n const hold = (n: number) => Math.round(Math.max(P.holdMin, P.holdBase + P.holdPerWord * n) * s);\n const w: Windows = {};\n for (const c of copy) {\n const end = c.out ?? total;\n const chain = c.chain ? copy.filter((x) => x.chain === c.chain).sort((a, b) => a.in - b.in) : [c];\n const last = chain[chain.length - 1];\n const next = chain[chain.indexOf(c) + 1];\n const own: [number, number] = next ? [c.resolved, Math.min(next.in, c.resolved + hold(wordsOf(c.text)), end)] : [c.resolved, Math.min(end, c.resolved + cap)];\n const line: [number, number] | null = next && (c.mode ?? 'build') === 'build' ? [last.resolved, Math.min(last.out ?? total, last.resolved + cap)] : null;\n const list: [number, number][] = line ? [own, line] : [own];\n w[String(c.id)] = list.filter(([a, b]) => b > a);\n }\n return w;\n};\nconst isWindows = (v: unknown): v is Windows =>\n !!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)));\n\n/**\n * The timeline module. A Remotion timeline.ts is an ES module. A HyperFrames timeline.js is a classic\n * script that sets FILM (CommonJS under Node, so the import gives it as `default`), with PACE_NAME\n * and PACE in the motion.js beside it; one that cannot be imported (in an ES-module package) runs\n * in a vm after its motion.js, as the browser loads them.\n */\nasync function loadTimeline(path: string): Promise\u003cRecord\u003cstring, unknown>> {\n let T: Record\u003cstring, unknown>;\n let M: Record\u003cstring, unknown> | undefined;\n const motion = join(dirname(path), 'motion.js');\n try {\n const mod = (await import(pathToFileURL(path).href)) as Record\u003cstring, unknown>;\n T = mod.default && mod.FPS === undefined ? { ...(mod.default as object) } : mod;\n } catch (e) {\n if (!/\\.c?js$/.test(path)) throw e;\n const ctx: Record\u003cstring, unknown> = {};\n ctx.window = ctx;\n createContext(ctx);\n if (existsSync(motion)) runInContext(readFileSync(motion, 'utf8'), ctx, { filename: motion });\n runInContext(readFileSync(path, 'utf8'), ctx, { filename: path });\n if (!ctx.FILM) throw e;\n T = { ...(ctx.FILM as object) };\n M = ctx.MOTION as Record\u003cstring, unknown> | undefined;\n }\n if (T.PACE_NAME === undefined && !M && existsSync(motion)) {\n try {\n M = createRequire(path)(motion) as Record\u003cstring, unknown>;\n } catch {\n /* an ES-module motion.js: the pace falls back to crisp, as for a timeline written before paces */\n }\n }\n if (T.PACE_NAME === undefined && M && typeof M.PACE_NAME === 'string') Object.assign(T, { PACE_NAME: M.PACE_NAME, PACE: T.PACE ?? M.PACE });\n return T;\n}\n\nasync function main() {\n if (!existsSync(tlPath)) die(`${tlPath} not found`);\n let T: Record\u003cstring, unknown>;\n try {\n T = await loadTimeline(tlPath);\n } catch (e) {\n return die(`importing ${tlPath} failed (keep timeline.ts free of React/CSS imports): ${(e as Error).message}`);\n }\n const src = readFileSync(tlPath, 'utf8');\n const FPS = Number(T.FPS);\n const BPM = Number(T.BPM);\n const TOTAL = Number(T.TOTAL);\n const ACT = T.ACT as Record\u003cstring, Act> | undefined;\n const CUE = T.CUE as Record\u003cstring, number | number[]> | undefined;\n const COPY = (T.COPY as Copy[] | undefined) ?? [];\n for (const [k, v] of Object.entries({ FPS, BPM, TOTAL })) if (!(v > 0)) die(`timeline must export a positive number ${k}`);\n if (!ACT || typeof ACT !== 'object') die('timeline must export ACT ({name: {from, dur}})');\n if (!CUE || typeof CUE !== 'object') die('timeline must export CUE ({name: frame})');\n if (!Array.isArray(COPY)) die('COPY must be an array of {id, text, in, resolved, out}');\n\n const paceName = (val('--pace') ?? (typeof T.PACE_NAME === 'string' ? T.PACE_NAME : 'crisp')) as 'calm' | 'crisp';\n if (!(paceName in BUILTIN)) die(`--pace must be calm or crisp (got ${paceName})`);\n const exported = (T.PACE as Record\u003cstring, Partial\u003cPace>> | undefined)?.[paceName];\n const P: Pace = { ...BUILTIN[paceName], ...(exported ?? {}) };\n\n const s = FPS / 60; // the 60 fps rules, scaled to this film\n const maxWords = Number(val('--max-words') ?? P.maxWords);\n const maxGap = Number(val('--max-gap') ?? Math.round(P.maxGap * s));\n const BEAT = (60 / BPM) * FPS;\n const BAR = BEAT * 4;\n const S16 = BEAT / 4;\n const TAIL = T.TAIL !== undefined ? Number(T.TAIL) : 0;\n const bodyEnd = TOTAL - TAIL;\n const out: Finding[] = [];\n const err = (check: string, message: string) => out.push({ check, severity: 'error', message });\n const warn = (check: string, message: string) => out.push({ check, severity: 'warn', message });\n\n // Off-grid reasons: `name: value, // offgrid: why` on the declaring line, or export OFFGRID = {name: 'why'}.\n const offgrid = new Map\u003cstring, string>();\n for (const m of src.matchAll(/^\\s*([A-Za-z_$][\\w$]*)\\s*:.*\\/\\/.*\\boffgrid:\\s*(.+)$/gm)) offgrid.set(m[1], m[2].trim());\n for (const [k, v] of Object.entries((T.OFFGRID as Record\u003cstring, string>) ?? {})) offgrid.set(k, String(v));\n\n // --- beat\n if (Math.abs(BEAT - Math.round(BEAT)) > 1e-9) {\n const ok = [];\n for (let b = 60; b \u003c= 200; b++) if (Number.isInteger((60 / b) * FPS)) ok.push(b);\n err('beat-whole', `a beat is ${BEAT.toFixed(3)} f at ${BPM} BPM / ${FPS} fps; use a BPM with whole beats: ${ok.join(', ')}`);\n }\n if (T.BEAT !== undefined && Math.abs(Number(T.BEAT) - BEAT) > 1e-9) err('beat-whole', `exported BEAT ${T.BEAT} != 60/BPM*FPS = ${BEAT}`);\n if (T.BAR !== undefined && Math.abs(Number(T.BAR) - BAR) > 1e-9) err('beat-whole', `exported BAR ${T.BAR} != 4 beats = ${BAR}`);\n\n // --- acts\n const acts = Object.entries(ACT!).sort((a, b) => a[1].from - b[1].from);\n let at = 0;\n for (const [name, a] of acts) {\n if (!(a.dur > 0)) err('acts', `ACT.${name} has dur ${a.dur}`);\n if (a.from !== at) err('acts', `ACT.${name} starts at ${a.from}, expected ${at} (${a.from > at ? 'gap' : 'overlap'} of ${Math.abs(a.from - at)} f)`);\n const off = Math.abs(a.from - Math.round(a.from / BAR) * BAR);\n if (off > 0.5 && !offgrid.has(name)) warn('acts-on-bar', `ACT.${name} starts at ${a.from}, ${off.toFixed(1)} f off a bar line`);\n at = a.from + a.dur;\n }\n if (acts.length && at !== TOTAL) err('acts', `acts end at ${at} but TOTAL is ${TOTAL}`);\n\n // --- cues\n const cueFrames: { name: string; f: number }[] = [];\n for (const [name, v] of Object.entries(CUE!)) {\n const list = Array.isArray(v) ? v : [v];\n list.forEach((f, i) => {\n const label = Array.isArray(v) ? `${name}[${i}]` : name;\n if (typeof f !== 'number' || !Number.isFinite(f)) return err('cue-grid', `CUE.${label} is not a frame number (${f})`);\n if (!Number.isInteger(f)) err('cue-grid', `CUE.${label} = ${f} is fractional; cues are whole frames`);\n if (f \u003c 0 || f >= TOTAL) err('cue-grid', `CUE.${label} = ${f} is outside the film [0, ${TOTAL})`);\n const off = Math.abs(f - Math.round(f / S16) * S16);\n if (off > 0.5 + 1e-9 && !offgrid.has(name))\n err('cue-grid', `CUE.${label} = ${f} is ${off.toFixed(2)} f off the 16th grid (${S16} f); snap it with b() or add \"// offgrid: \u003creason>\"`);\n cueFrames.push({ name: label, f });\n });\n }\n\n // --- copy: one entry at a time\n let words = 0;\n let uiWords = 0;\n const holdNeed = (n: number) => Math.round(Math.max(P.holdMin, P.holdBase + P.holdPerWord * n) * s);\n const fragErr = paceName === 'calm' ? P.maxNewWords + 2 : 10;\n const valid: (Copy & { out: number; n: number; ui: boolean })[] = [];\n for (const c of COPY) {\n const id = c.id ?? '?';\n const n = wordsOf(c.text);\n const ui = c.kind === 'ui'; // product strings: a typed command, code, a URL, an app label\n if (ui) uiWords += n;\n else words += n;\n const o = c.out ?? TOTAL;\n if (![c.in, c.resolved, o].every(Number.isFinite)) {\n err('copy', `COPY ${id}: in/resolved/out must be frames`);\n continue;\n }\n if (!(c.in \u003c= c.resolved && c.resolved \u003c= o)) err('copy', `COPY ${id}: needs in \u003c= resolved \u003c= out (got ${c.in}, ${c.resolved}, ${o}); unresolved text never exits`);\n if (c.in \u003c 0 || o > TOTAL) err('copy', `COPY ${id}: [${c.in}, ${o}] is outside the film [0, ${TOTAL}]`);\n valid.push({ ...c, out: o, n, ui });\n const hold = o - c.resolved;\n if (hold \u003c holdNeed(n) && ui) warn('text-hold', `COPY ${id} (ui) \"${c.text}\" holds ${hold} f resolved; ${n} word(s) want >= ${holdNeed(n)} f if it must be read`);\n else if (hold \u003c holdNeed(n)) err('text-hold', `COPY ${id} \"${c.text}\" holds ${hold} f resolved; ${n} word(s) need >= ${holdNeed(n)} f`);\n if (ui) continue; // UI strings keep the product's own layout\n if (n > fragErr) err('fragment', `COPY ${id} \"${c.text}\" shows ${n} words at once; a thought longer than ${P.maxNewWords} words arrives as a chain of fragments (timeline.ts chain())`);\n else if (n > P.maxNewWords) warn('fragment', `COPY ${id} \"${c.text}\" has ${n} words; a fragment holds 1-${P.maxNewWords} (split it into a chain)`);\n const lines = String(c.text).split('\\n');\n if (lines.length > 2) warn('copy-lines', `COPY ${id}: ${lines.length} lines on screen at once (keep \u003c= 2)`);\n const rate = (c.resolved - c.in) / Math.max(1, n - 1);\n if (n >= 3 && rate \u003c 8 * s) warn('reveal-rate', `COPY ${id} \"${c.text}\" reveals ${n} words in ${c.resolved - c.in} f (${rate.toFixed(1)} f per word \u003c ${8 * s}): a block, not a read; stagger the words or split the line`);\n }\n if (words > maxWords) err('word-total', `${words} on-screen words; the budget is ${maxWords} (--max-words). Typed commands, code, URLs and app labels belong in COPY with kind: 'ui' and are not budgeted`);\n\n // --- reading windows: the timeline's READ, else the cues.json copy block's `read`, else the same rule here\n const fromCues = (() => {\n const p = val('--cues');\n if (!p || !existsSync(p)) return null;\n const w: Windows = {};\n for (const c of JSON.parse(readFileSync(p, 'utf8')).copy ?? []) if (isWindows({ x: c.read })) w[String(c.id)] = c.read;\n return Object.keys(w).length ? w : null;\n })();\n const READ: Windows = isWindows(T.READ) ? (T.READ as Windows) : (fromCues ?? readingWindows(valid, P, FPS, TOTAL));\n\n // --- chains: fragments of one thought\n const chains = new Map\u003cstring, typeof valid>();\n for (const c of valid) if (c.chain) chains.set(c.chain, [...(chains.get(c.chain) ?? []), c]);\n const exitF = Math.round(P.exitDur * s); // a leaving entry is seen until its exit is over\n const calm = paceName === 'calm';\n const sev = (check: string, message: string) => (calm ? err : warn)(check, message);\n const soundEvents: { f: number; kind: string }[] = [];\n const cuesPath = val('--cues');\n let cuesDoc: { events?: { f: number; kind?: string }[]; copy?: { id: string; read?: unknown }[] } | null = null;\n if (cuesPath) {\n if (!existsSync(cuesPath)) die(`--cues ${cuesPath} not found (run npx tsx scripts/export-cues.ts)`);\n cuesDoc = JSON.parse(readFileSync(cuesPath, 'utf8'));\n for (const e of cuesDoc!.events ?? []) if (Number.isFinite(e.f)) soundEvents.push({ f: e.f, kind: String(e.kind ?? '') });\n }\n for (const [name, list] of chains) {\n list.sort((a, b) => a.in - b.in);\n const modes = new Set(list.map((c) => c.mode ?? 'build'));\n if (modes.size > 1) err('chain-gap', `chain ${name} mixes modes (${[...modes].join(', ')}); a chain either builds one line or replaces in place`);\n const mode = list[0].mode ?? 'build';\n if (mode === 'build') {\n const outs = new Set(list.map((c) => c.out));\n if (outs.size > 1) err('chain-gap', `build chain ${name} leaves at ${[...outs].join(', ')}; a built line clears as a whole (one out for every fragment)`);\n const last = list[list.length - 1];\n const total = list.reduce((n, c) => n + c.n, 0);\n if (list.length > 1 && last.out - last.resolved \u003c holdNeed(total))\n err('text-hold', `build chain ${name} holds ${last.out - last.resolved} f after its last fragment; its ${total} words need >= ${holdNeed(total)} f`);\n }\n for (let i = 1; i \u003c list.length; i++) {\n const [a, b] = [list[i - 1], list[i]];\n const read = a.resolved + holdNeed(a.n);\n const gap = b.in - read;\n if (gap \u003c 0) err('chain-gap', `${b.id} arrives at f${b.in}, ${-gap} f before ${a.id} has been read (resolved f${a.resolved} + ${holdNeed(a.n)} f)`);\n else if (gap \u003c Math.round(P.gapMin * s)) sev('chain-gap', `${a.id} -> ${b.id}: ${gap} f of picture between the read and the next fragment; give the picture its beat (${Math.round(P.gapMin * s)}-${Math.round(P.gapMax * s)} f)`);\n else if (gap > Math.round(P.gapMax * s)) warn('chain-gap', `${a.id} -> ${b.id}: ${gap} f between the read and the next fragment; past ${Math.round(P.gapMax * s)} f the thought breaks in two`);\n if (gap >= Math.round(P.gapMin * s)) {\n // the picture beat must be something the picture does: a cue, or a sound that is not a text sound\n const beat = cueFrames.some((c) => c.f >= read && c.f \u003c b.in) || soundEvents.some((e) => e.kind !== 'text' && e.f >= read && e.f \u003c b.in);\n if (!beat)\n sev('chain-beat', `${a.id} -> ${b.id}: nothing happens between the read (f${read}) and the next fragment (f${b.in}): no CUE${cuesPath ? ' and no sound other than text' : ''} there. The picture takes its turn between fragments (the product does what the fragment set up): give it a cue`);\n }\n const clear = Math.round((P.exitDur + 6) * s);\n if (mode === 'replace' && b.in \u003c a.out + clear)\n err('chain-gap', `replace chain ${name}: ${b.id} arrives at f${b.in} while ${a.id} is still leaving (out f${a.out} + a ${exitF} f exit); start it >= ${clear} f after ${a.id}'s out, so its exit is over first`);\n }\n }\n\n // --- separate entries that arrive together (not one chain): the earlier one is read first\n const film = valid.filter((c) => !c.ui);\n const byIn = [...film].sort((a, b) => a.in - b.in);\n for (let i = 0; i \u003c byIn.length; i++)\n for (let j = i + 1; j \u003c byIn.length; j++) {\n const [a, b] = [byIn[i], byIn[j]];\n if (a.chain && a.chain === b.chain) continue; // chain-gap checks a chain's own fragments\n if (b.in >= a.out) continue; // a has left before b arrives\n const read = a.resolved + holdNeed(a.n);\n if (b.in \u003c read)\n sev('read-first', `${b.id} \"${b.text}\" arrives at f${b.in} while ${a.id} \"${a.text}\" is on screen and not yet read (resolved f${a.resolved} + ${holdNeed(a.n)} f): two lines at once read as one sentence dumped. Write them as one chain() so they take turns, or bring ${b.id} in after f${read}`);\n }\n\n // --- what is on screen, frame by frame (film copy; ui counted apart)\n let maxVisible = 0;\n let worst: { f: number; ids: string[]; n: number; limit: number } | null = null;\n let textFrames = 0;\n let maxBoth = 0;\n const free: boolean[] = [];\n for (let f = 0; f \u003c TOTAL; f++) {\n const on = film.filter((c) => c.in \u003c= f && f \u003c c.out); // on screen until its exit begins (text-share)\n const seen = film.filter((c) => c.in \u003c= f && f \u003c c.out + exitF); // ... and until its exit is over (concurrency)\n const n = seen.reduce((m, c) => m + c.n, 0);\n const both = n + valid.filter((c) => c.ui && c.in \u003c= f && f \u003c c.out + exitF).reduce((m, c) => m + c.n, 0);\n const limit = seen.length && seen.every((c) => c.mode === 'replace') ? P.maxVisibleReplace : P.maxVisibleBuild;\n maxVisible = Math.max(maxVisible, n);\n maxBoth = Math.max(maxBoth, both);\n if (n > limit && (!worst || n - limit > worst.n - worst.limit)) worst = { f, ids: seen.map((c) => c.id), n, limit };\n if (f \u003c bodyEnd) {\n if (on.length) textFrames++;\n free.push(!on.length);\n }\n }\n if (worst) err('copy-concurrent', `${worst.n} film words on screen at f${worst.f} (${worst.ids.join(', ')}); at most ${worst.limit} at once: one fragment at a time`);\n if (maxBoth > 14) warn('copy-concurrent', `up to ${maxBoth} words (film copy and ui) readable at once; past 14 the frame reads as a page`);\n\n // --- copy rate and text share\n const win5 = Math.round(5 * FPS);\n const resolves = film.map((c) => ({ f: c.resolved, n: c.n })).sort((a, b) => a.f - b.f);\n let peak = { n: 0, f: 0 };\n for (const r of resolves) {\n const n = resolves.filter((x) => x.f >= r.f && x.f \u003c r.f + win5).reduce((m, x) => m + x.n, 0);\n if (n > peak.n) peak = { n, f: r.f };\n }\n const maxIn5 = Math.round(5 * P.maxWordsPerSec);\n if (peak.n > maxIn5) warn('word-rate', `${peak.n} film words resolve in the 5 s from f${peak.f}; keep \u003c= ${maxIn5} (${P.maxWordsPerSec} w/s): fewer words, or let the picture take the time`);\n const bodySec = bodyEnd / FPS;\n const avg = bodySec > 0 ? words / bodySec : 0;\n if (avg > P.avgWordsPerSec) warn('word-rate', `${avg.toFixed(2)} film words per second over the body; keep \u003c= ${P.avgWordsPerSec}`);\n const share = bodyEnd > 0 ? textFrames / bodyEnd : 0;\n if (film.length && share > P.textShare)\n (calm && share > P.textShare + 0.1 ? err : warn)('text-share', `film copy is on screen on ${(100 * share).toFixed(1)}% of the body; keep \u003c= ${Math.round(100 * P.textShare)}%: give the picture beats with no copy`);\n // the longest text-free run inside every 10 s window of the body (the whole body when shorter)\n const freeNeed = Math.round(P.freeRun * s);\n const win10 = Math.min(bodyEnd, Math.round(10 * FPS));\n const runIn = (a: number, b: number) => {\n let best = 0;\n let cur = 0;\n for (let f = a; f \u003c b; f++) best = Math.max(best, (cur = free[f] ? cur + 1 : 0));\n return best;\n };\n let longestFree = runIn(0, bodyEnd);\n if (film.length && freeNeed > 0 && win10 > 0) {\n for (let a = 0; a + win10 \u003c= bodyEnd; a += Math.max(1, Math.round(FPS / 2))) {\n const run = runIn(a, a + win10);\n if (run \u003c freeNeed) {\n warn('text-share', `no text-free stretch of ${freeNeed} f between f${a} and f${a + win10} (longest ${run} f): every 10 s needs a picture beat with no copy`);\n break;\n }\n }\n }\n\n // --- event gaps (the tail is for the audio to decay, so it is excluded)\n const events = new Set\u003cnumber>([0, bodyEnd]);\n // One designed near-still hold per film (the key claim, under a music dropout) may run past the gap limit.\n const HOLD = T.HOLD as { from: number; to: number } | undefined;\n const holdMax = Math.round(96 * s);\n let hold: { from: number; to: number } | null = null;\n if (HOLD !== undefined) {\n if (!HOLD || !Number.isFinite(HOLD.from) || !Number.isFinite(HOLD.to) || HOLD.to \u003c= HOLD.from) {\n err('hold', 'HOLD must be {from, to} frames with to > from');\n } else if (HOLD.to - HOLD.from > holdMax) {\n err('hold', `HOLD runs ${HOLD.to - HOLD.from} f; a designed hold is at most ${holdMax} f (96 f at 60 fps)`);\n } else {\n hold = HOLD;\n events.add(HOLD.from);\n events.add(HOLD.to);\n }\n }\n for (const c of cueFrames) events.add(c.f);\n for (const [, a] of acts) events.add(a.from);\n for (const c of valid) for (const f of [c.in, c.resolved, c.out]) events.add(f);\n for (const e of soundEvents) events.add(Math.round(e.f));\n // Reading windows: a resolved line being read is alive, so it fills the gap it sits in.\n const reading = Object.values(READ).flat();\n const uncovered = (a: number, b: number) => {\n // the longest run of frames in (a, b) that no reading window covers\n let best = 0;\n let cur = 0;\n for (let f = a + 1; f \u003c b; f++) best = Math.max(best, (cur = reading.some(([x, y]) => f >= x && f \u003c y) ? 0 : cur + 1));\n return best + 1; // + 1: a run of k uncovered frames between two events spans k + 1 frame steps\n };\n const ev = [...events].filter((f) => f >= 0 && f \u003c= bodyEnd).sort((a, b) => a - b);\n let longestGap = 0;\n for (let i = 1; i \u003c ev.length; i++) {\n const gap = ev[i] - ev[i - 1];\n if (hold && ev[i - 1] >= hold.from && ev[i] \u003c= hold.to) continue; // the declared hold\n const live = gap > maxGap ? uncovered(ev[i - 1], ev[i]) : gap;\n longestGap = Math.max(longestGap, Math.min(gap, live));\n if (gap > maxGap && live > maxGap) {\n const near = cueFrames.filter((c) => c.f === ev[i - 1] || c.f === ev[i]).map((c) => c.name);\n err('event-gap', `${live} f without an event or a line being read between f${ev[i - 1]} and f${ev[i]}${near.length ? ` (${near.join(', ')})` : ''}; add the beat that keeps it alive (a move, a peak, a drift under a read) or --max-gap`);\n }\n }\n\n // --- read-quiet: nothing new starts while a fragment is read (calm)\n if (calm) {\n const textFrames = new Set(valid.flatMap((c) => [c.in, c.resolved, c.out]));\n const loud = new Set\u003cstring>();\n for (const c of valid) {\n if (c.ui) continue;\n for (const [a, b] of READ[String(c.id)] ?? [])\n for (const q of cueFrames)\n if (q.f >= a && q.f \u003c b && q.f \u003c bodyEnd && !textFrames.has(q.f) && !loud.has(q.name)) {\n loud.add(q.name);\n warn('read-quiet', `CUE.${q.name} (f${q.f}) starts while ${c.id} \"${c.text}\" is being read (f${a}-${b}): nothing new starts during a reading hold; move it after the read, where the picture takes its turn`);\n }\n }\n }\n\n // --- tail and bars\n const lastCue = cueFrames.reduce((m, c) => Math.max(m, c.f), 0);\n if (TOTAL - lastCue \u003c FPS) warn('tail', `only ${TOTAL - lastCue} f after the last cue; leave >= ${FPS} f (1 s) for the audio to decay`);\n if (T.TAIL !== undefined && TAIL \u003c FPS) warn('tail', `TAIL is ${TAIL} f; use >= ${FPS} f (1 s)`);\n if (Math.abs(bodyEnd / BAR - Math.round(bodyEnd / BAR)) > 1e-6) warn('whole-bars', `the body (TOTAL - TAIL = ${bodyEnd} f) is ${(bodyEnd / BAR).toFixed(2)} bars; whole bars loop and end cleanly`);\n\n // --- report\n const errors = out.filter((x) => x.severity === 'error').length;\n const warnings = out.length - errors;\n const pass = errors === 0 && !(strict && warnings > 0);\n const summary = {\n fps: FPS,\n bpm: BPM,\n beat: BEAT,\n bar: BAR,\n total: TOTAL,\n seconds: +(TOTAL / FPS).toFixed(3),\n acts: acts.length,\n cues: cueFrames.length,\n pace: paceName,\n words,\n uiWords,\n maxWords,\n maxVisible,\n wordsPerSec: +avg.toFixed(2),\n peakWordsIn5s: peak.n,\n textShare: +share.toFixed(3),\n longestTextFree: longestFree,\n maxGap,\n longestGap,\n };\n if (json) console.log(JSON.stringify({ tool: 'grid-check', timeline: tlPath, pass, errors, warnings, summary, findings: out }, null, 1));\n else {\n for (const f of out) console.log(`${f.severity === 'error' ? 'FAIL' : 'warn'} ${f.check} ${f.message}`);\n console.log(\n `grid-check: ${summary.seconds} s, ${BPM} BPM @ ${FPS} fps (beat ${BEAT} f, bar ${BAR} f), ${paceName} pace, ${summary.acts} acts, ${summary.cues} cues, ` +\n `${words}/${maxWords} words${uiWords ? ` (+${uiWords} ui)` : ''}, \u003c= ${maxVisible} on screen, ${summary.wordsPerSec} w/s, ` +\n `text on ${Math.round(100 * share)}% (longest text-free ${longestFree} f), longest gap ${longestGap}/${maxGap} f -> ${pass ? 'pass' : 'FAIL'} (${errors} errors, ${warnings} warnings)`,\n );\n }\n process.exit(pass ? 0 : 1);\n}\n\nmain();\n","content_type":"application/typescript; charset=utf-8","language":"typescript","size":32109,"content_sha256":"e11dd70ee2ea173b2871c4f320bd613140c75bd9146ee988ef1174b141d4711f"},{"filename":"scripts/hf-finish.sh","content":"#!/usr/bin/env bash\n# hf-finish.sh: render and finish a HyperFrames film the cinetic way.\n#\n# preflight (hf-preflight.cjs) -> lint (0 errors) -> check -> PNG-sequence render\n# -> one BT.709 encode -> mux the mastered WAV -> check-sync.py -> probe.py\n#\n# Why not `hyperframes render` straight to MP4: that path captures JPEG q95 and then encodes\n# again (two lossy steps, visible on dark gradients), defaults to CRF 16 and AAC 192k, and has\n# no loudness stage and no motion blur in the CLI. Rendering PNGs and encoding once here fixes\n# the picture; the soundtrack comes in already mastered from scripts/audio/ and is muxed here.\n# --blur K renders at fps*K and averages the sub-frames with accumulate.py --uniform K.\n#\n# Usage:\n# bash scripts/hf-finish.sh \u003cproject> \u003cout.mp4> [--audio wav] [--fps 60] [--blur K]\n# [--shutter 240] [--crf 14] [--preview] [--composition file.html] [--workers 2]\n# [--skip-check] [--keep] [--allow-transparent] [--variables JSON] [--json report.json]\n# bash scripts/hf-finish.sh \u003cproject> --export-cues out/cues.json\n#\n# --audio WAV mastered soundtrack to mux (AAC 320k, 48 kHz); checked with check-sync.py\n# --fps N output fps (default: root data-fps, else motion.js FPS)\n# --blur K render at fps*K (\u003c= 240) and accumulate K sub-frames per frame\n# --shutter DEG shutter for --blur (default 240; accumulate.py centres the samples)\n# --crf N x264 CRF (default 14; --preview uses 20 and preset veryfast)\n# --preview fast iteration: skips check, no blur, CRF 20\n# --composition F render this file instead of index.html (passed to render -c)\n# --workers N HyperFrames capture workers (default 2; each is a Chromium process)\n# --skip-check skip `hyperframes check` (lint still runs)\n# --keep keep the PNG frames in \u003cout dir>/.hf-finish-\u003cname>/ (the last one is always\n# kept as \u003cout dir>/qa/\u003cname>-last-frame.png, deliver.sh's clean poster source)\n# --allow-transparent accept transparent pixels (they encode as black); default is to fail\n# --variables JSON variable values for this render (one row of a --batch, finished properly)\n# --json FILE also write the run report there (it is always printed on stdout)\n# --export-cues F write FILM.cues() from motion.js + timeline.js to F and exit\n#\n# Env: HF_CLI (default \"npx -y [email protected]\"). Browser: HYPERFRAMES_BROWSER_PATH, else\n# CHROMIUM_PATH or REMOTION_BROWSER, else a sandbox headless shell under /opt/pw-browsers, else\n# HyperFrames downloads its own. Telemetry is off unless HYPERFRAMES_NO_TELEMETRY is set to 0.\n# Exit: 0 every gate passed, 1 a gate failed, 2 usage error.\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\ndie() { echo \"hf-finish: $*\" >&2; exit 1; }\nusage() { # usage [status]: help on stdout for --help (exit 0), on stderr for misuse (exit 2)\n if [ \"${1:-2}\" = 0 ]; then sed -n '2,38p' \"${BASH_SOURCE[0]}\" | sed 's/^# \\{0,1\\}//'; exit 0; fi\n sed -n '2,38p' \"${BASH_SOURCE[0]}\" | sed 's/^# \\{0,1\\}//' >&2; exit 2\n}\nlog() { echo \"hf-finish: $*\" >&2; }\nESC=\"$(printf '\\033')\" # strips colour codes from logs; BSD sed has no \\x1b\n\n[ $# -ge 1 ] || usage\ncase \"$1\" in -h|--help) usage 0 ;; esac\nPROJ=\"$1\"; shift\nOUT=\"\"; AUDIO=\"\"; FPS=\"\"; BLUR=1; SHUTTER=240; CRF=\"\"; PREVIEW=0; COMP=\"\"; WORKERS=2\nSKIP_CHECK=0; KEEP=0; JSON=\"\"; CUES_OUT=\"\"; ALLOW_ALPHA=0; VARS=\"\"\nif [ $# -ge 1 ] && [ \"${1#--}\" = \"$1\" ]; then OUT=\"$1\"; shift; fi\nwhile [ $# -gt 0 ]; do\n case \"$1\" in\n --audio) AUDIO=\"${2:?--audio needs a file}\"; shift 2 ;;\n --fps) FPS=\"${2:?}\"; shift 2 ;;\n --blur) BLUR=\"${2:?}\"; shift 2 ;;\n --shutter) SHUTTER=\"${2:?}\"; shift 2 ;;\n --crf) CRF=\"${2:?}\"; shift 2 ;;\n --preview) PREVIEW=1; shift ;;\n --composition) COMP=\"${2:?}\"; shift 2 ;;\n --workers) WORKERS=\"${2:?}\"; shift 2 ;;\n --skip-check) SKIP_CHECK=1; shift ;;\n --keep) KEEP=1; shift ;;\n --allow-transparent) ALLOW_ALPHA=1; shift ;;\n --variables) VARS=\"${2:?}\"; shift 2 ;;\n --json) JSON=\"${2:?}\"; shift 2 ;;\n --export-cues) CUES_OUT=\"${2:?}\"; shift 2 ;;\n -h|--help) usage 0 ;;\n *) echo \"hf-finish: unknown option $1\" >&2; usage ;;\n esac\ndone\n[ -d \"$PROJ\" ] || { echo \"hf-finish: no project directory $PROJ\" >&2; exit 2; }\nPROJ=\"$(cd \"$PROJ\" && pwd)\"\nENTRY=\"${COMP:-index.html}\"\n[ -f \"$PROJ/$ENTRY\" ] || { echo \"hf-finish: missing $PROJ/$ENTRY\" >&2; exit 2; }\ncommand -v node >/dev/null || die \"node is required\"\n\n# ---- preflight: timeline vs root, fonts and local scripts on disk, cue export -------------\n# in its own file: bash 3.2 (macOS) cannot parse a heredoc inside $( )\n[ -f \"$SCRIPT_DIR/hf-preflight.cjs\" ] || die \"missing $SCRIPT_DIR/hf-preflight.cjs: copy scripts/ from the skill into the project again\"\nPRE=\"$(PROJ=\"$PROJ\" ENTRY=\"$ENTRY\" CUES_OUT=\"$CUES_OUT\" node \"$SCRIPT_DIR/hf-preflight.cjs\")\" || die \"preflight crashed\"\neval \"$PRE\"\n[ \"$P_ERR\" = 0 ] || die \"preflight failed ($P_ERR problem(s) above)\"\nif [ -n \"$CUES_OUT\" ]; then log \"wrote $CUES_OUT\"; exit 0; fi\n[ -n \"$OUT\" ] || usage\n\nFPS=\"${FPS:-${P_ROOTFPS:-${P_TLFPS:-60}}}\"\n[ \"$PREVIEW\" = 1 ] && { BLUR=1; SKIP_CHECK=1; CRF=\"${CRF:-20}\"; PRESET=veryfast; } || PRESET=slow\nCRF=\"${CRF:-14}\"\ncase \"$BLUR\" in ''|*[!0-9]*) die \"--blur takes a whole number\" ;; esac\nRFPS=$((FPS * BLUR))\n[ \"$RFPS\" -le 240 ] || die \"--blur $BLUR at $FPS fps needs $RFPS fps; HyperFrames caps at 240 (use K \u003c= $((240 / FPS)), or Remotion for faster motion)\"\n[ -n \"$P_TLFPS\" ] && [ \"$FPS\" != \"$P_TLFPS\" ] && log \"warning: rendering at $FPS fps but the grid in motion.js is $P_TLFPS fps\"\nif [ -n \"$AUDIO\" ]; then [ -f \"$AUDIO\" ] || die \"missing audio $AUDIO\"; AUDIO=\"$(cd \"$(dirname \"$AUDIO\")\" && pwd)/$(basename \"$AUDIO\")\"; fi\ncommand -v ffmpeg >/dev/null || die \"ffmpeg is required\"\n\nmkdir -p \"$(dirname \"$OUT\")\"\nOUT=\"$(cd \"$(dirname \"$OUT\")\" && pwd)/$(basename \"$OUT\")\"\nNAME=\"$(basename \"$OUT\" .mp4)\"\nWORK=\"$(dirname \"$OUT\")/.hf-finish-$NAME\"\nQA=\"$(dirname \"$OUT\")/qa\"\nmkdir -p \"$QA\"\nif [ -d \"$WORK\" ]; then rm -rf -- \"$WORK\"; fi\nmkdir -p \"$WORK\"\ncleanup() { if [ \"$KEEP\" = 0 ] && [ -n \"$WORK\" ] && [ -d \"$WORK\" ]; then rm -rf -- \"$WORK\"; fi; }\ntrap cleanup EXIT\n\nread -r -a HF \u003c\u003c\u003c \"${HF_CLI:-npx -y [email protected]}\"\nexport HYPERFRAMES_NO_TELEMETRY=\"${HYPERFRAMES_NO_TELEMETRY:-1}\" HYPERFRAMES_SKIP_SKILLS=1\nif [ -z \"${HYPERFRAMES_BROWSER_PATH:-}\" ] && [ -z \"${PRODUCER_HEADLESS_SHELL_PATH:-}\" ]; then\n for c in \"${CHROMIUM_PATH:-}\" \"${REMOTION_BROWSER:-}\" /opt/pw-browsers/chromium_headless_shell-*/chrome-linux/headless_shell; do\n if [ -n \"$c\" ] && [ -x \"$c\" ]; then export HYPERFRAMES_BROWSER_PATH=\"$c\"; break; fi\n done\nfi\n[ -n \"${HYPERFRAMES_BROWSER_PATH:-}\" ] && log \"browser: $HYPERFRAMES_BROWSER_PATH\"\n\nSTEPS=\"\"\nstep() { STEPS=\"$STEPS\\\"$1\\\":\\\"$2\\\",\"; }\nreport() {\n local ok=\"$1\"\n local r=\"{\\\"ok\\\":$ok,\\\"out\\\":\\\"$OUT\\\",\\\"fps\\\":$FPS,\\\"blur\\\":$BLUR,\\\"render_fps\\\":$RFPS,\\\"crf\\\":$CRF,\\\"steps\\\":{${STEPS%,}}}\"\n echo \"$r\"\n if [ -n \"$JSON\" ]; then mkdir -p \"$(dirname \"$JSON\")\"; echo \"$r\" > \"$JSON\"; fi\n}\nfail_step() { step \"$1\" fail; report false; die \"$2\"; }\n\n# ---- 1. lint: zero errors, or check's layout audit silently samples nothing ---------------\nset +e\n${HF[@]+\"${HF[@]}\"} lint \"$PROJ\" --json > \"$QA/hf-lint.json\" 2> \"$QA/hf-lint.log\"\nset -e\nLINT=\"$(node -e '\nconst s = require(\"fs\").readFileSync(process.argv[1], \"utf8\");\nlet j; try { j = JSON.parse(s.slice(s.indexOf(\"{\"), s.lastIndexOf(\"}\") + 1)); } catch (e) { console.log(\"ERR -1\"); process.exit(0); }\nconst all = (j.results || []).flatMap((r) => (r.result && r.result.findings) || []).concat(j.findings || []);\nfor (const f of all) if (f.severity === \"error\") console.error(\" lint error \" + f.code + \": \" + f.message);\nconsole.log(\"ERR \" + (j.errorCount ?? -1) + \" WARN \" + (j.warningCount ?? 0));\n' \"$QA/hf-lint.json\")\"\nread -r _ LERR _ LWARN \u003c\u003c\u003c \"$LINT\"\n[ \"$LERR\" = 0 ] || { tail -n 20 \"$QA/hf-lint.log\" >&2; fail_step lint \"lint reported ${LERR} error(s); see $QA/hf-lint.json\"; }\nstep lint \"pass (${LWARN:-0} warnings)\"; log \"lint: 0 errors, ${LWARN:-0} warnings\"\n\n# ---- 2. check: runtime errors, layout, contrast, in one Chromium pass ----------------------\nif [ \"$SKIP_CHECK\" = 0 ]; then\n if ${HF[@]+\"${HF[@]}\"} check \"$PROJ\" > \"$QA/hf-check.log\" 2>&1; then\n step check pass; log \"check: pass ($QA/hf-check.log)\"\n else\n sed \"s/${ESC}\\[[0-9;]*m//g\" \"$QA/hf-check.log\" | tail -n 40 >&2\n fail_step check \"hyperframes check failed; fix the issues above or mark intentional ones (data-layout-allow-overflow / -occlusion)\"\n fi\nelse\n step check skipped\nfi\n\n# ---- 3. render PNG frames (lossless capture, no encoder) ----------------------------------\nRARGS=(render \"$PROJ\" --format png-sequence --fps \"$RFPS\" -o \"$WORK/frames\" --workers \"$WORKERS\" --strict --quiet)\n[ -n \"$COMP\" ] && RARGS+=(-c \"$COMP\")\n[ -n \"$VARS\" ] && RARGS+=(--variables \"$VARS\" --strict-variables)\nlog \"render: $RFPS fps PNG sequence ($WORKERS workers)\"\nif ! ${HF[@]+\"${HF[@]}\"} ${RARGS[@]+\"${RARGS[@]}\"} > \"$QA/hf-render.log\" 2>&1; then\n sed \"s/${ESC}\\[[0-9;]*m//g\" \"$QA/hf-render.log\" | tail -n 30 >&2\n fail_step render \"hyperframes render failed; see $QA/hf-render.log\"\nfi\nNF=$(find \"$WORK/frames\" -maxdepth 1 -name 'frame_*.png' | wc -l | tr -d '[:space:]') # BSD wc pads\n[ \"$NF\" -gt 0 ] || fail_step render \"render wrote no frames\"\nif [ -n \"$P_TOTAL\" ] && [ -n \"$P_TLFPS\" ]; then\n WANT=$(( (P_TOTAL * RFPS + P_TLFPS / 2) / P_TLFPS ))\n [ \"$NF\" -eq \"$WANT\" ] || fail_step render \"render wrote $NF frames, expected $WANT (TOTAL $P_TOTAL f at $RFPS fps)\"\nfi\n# PNG capture keeps alpha and drops the html/body background: uncovered pixels would encode black.\nfor F1 in \"$WORK/frames/frame_000001.png\" \"$WORK/frames/$(printf 'frame_%06d.png' \"$NF\")\"; do\n [ \"$(ffprobe -v error -show_entries stream=pix_fmt -of csv=p=0 \"$F1\")\" = rgba ] || continue\n AMIN=\"$(ffmpeg -v error -i \"$F1\" -vf \"alphaextract,signalstats,metadata=print:key=lavfi.signalstats.YMIN:file=-\" \\\n -f null - 2>/dev/null | sed -n 's/.*YMIN=//p' | head -n 1)\"\n if [ -n \"$AMIN\" ] && [ \"$AMIN\" -lt 255 ] && [ \"$ALLOW_ALPHA\" = 0 ]; then\n fail_step render \"$(basename \"$F1\") has transparent pixels (alpha min $AMIN): PNG renders do not capture the html/body background. Put the fill on a full-bleed .clip layer, or pass --allow-transparent\"\n fi\ndone\nstep render \"pass ($NF frames at $RFPS fps)\"; log \"render: $NF frames\"\n# the lossless last frame: deliver.sh takes the poster from it, not from a decoded H.264 frame\ncp \"$WORK/frames/$(printf 'frame_%06d.png' \"$NF\")\" \"$QA/$NAME-last-frame.png\"\n\n# ---- 4. encode once: BT.709 limited range, tagged -----------------------------------------\nPIC=\"$WORK/picture.mp4\"\nif [ \"$BLUR\" -gt 1 ]; then\n [ -f \"$SCRIPT_DIR/accumulate.py\" ] || fail_step encode \"--blur needs $SCRIPT_DIR/accumulate.py\"\n python3 \"$SCRIPT_DIR/accumulate.py\" \"$WORK/frames/frame_*.png\" \"$PIC\" --uniform \"$BLUR\" --in-fps \"$RFPS\" \\\n --shutter \"$SHUTTER\" --crf \"$CRF\" --preset \"$PRESET\" --quiet > /dev/null || fail_step encode \"accumulate.py failed\"\n step encode \"pass (blur K=$BLUR, shutter $SHUTTER, crf $CRF)\"\nelse\n # ffmpeg 7.1 and newer tag the stream from the frames, over -color_primaries/-color_trc, and scale\n # sets only the matrix and range on them: setparams sets all four (references/finishing.md, section 4)\n ffmpeg -v error -y -framerate \"$FPS\" -start_number 1 -i \"$WORK/frames/frame_%06d.png\" \\\n -vf \"scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+full_chroma_int,format=yuv420p,setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv\" \\\n -c:v libx264 -preset \"$PRESET\" -crf \"$CRF\" -pix_fmt yuv420p \\\n -colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \\\n -movflags +faststart \"$PIC\" || fail_step encode \"ffmpeg encode failed\"\n step encode \"pass (crf $CRF, $PRESET)\"\nfi\nlog \"encode: $PIC\"\n\n# ---- 5. mux: the mastered WAV, video length wins ------------------------------------------\nSIDE=\"$WORK/frames/audio.m4a\"\nSECS=\"$(awk -v n=\"$NF\" -v k=\"$BLUR\" -v f=\"$FPS\" 'BEGIN { printf \"%.6f\", int(n / k) / f }')\"\nif [ -n \"$AUDIO\" ]; then\n # pad then cut the audio to the picture's exact length (AAC frames would otherwise overhang)\n ffmpeg -v error -y -i \"$PIC\" -i \"$AUDIO\" -map 0:v:0 -map 1:a:0 -c:v copy \\\n -af \"apad=whole_dur=$SECS\" -c:a aac -b:a 320k -ar 48000 -ac 2 -t \"$SECS\" -movflags +faststart \"$OUT\" \\\n || fail_step mux \"ffmpeg mux failed\"\n step mux \"pass (aac 320k)\"\nelif [ -f \"$SIDE\" ]; then\n log \"warning: no --audio; muxing HyperFrames' own mix (AAC 192k, not mastered)\"\n ffmpeg -v error -y -i \"$PIC\" -i \"$SIDE\" -map 0:v:0 -map 1:a:0 -c copy -shortest -movflags +faststart \"$OUT\" || fail_step mux \"ffmpeg mux failed\"\n step mux \"pass (composition audio, unmastered)\"\nelse\n cp \"$PIC\" \"$OUT\"\n step mux \"none (no audio)\"\nfi\n\n# ---- 6. sync and spec gates ----------------------------------------------------------------\nif [ -n \"$AUDIO\" ] && [ -f \"$SCRIPT_DIR/check-sync.py\" ]; then\n if python3 \"$SCRIPT_DIR/check-sync.py\" \"$OUT\" \"$AUDIO\" --json \"$QA/sync.json\" --quiet > /dev/null; then\n step sync pass; log \"check-sync: pass ($QA/sync.json)\"\n else\n fail_step sync \"check-sync.py failed; see $QA/sync.json\"\n fi\nelse\n step sync skipped\nfi\nif [ -f \"$SCRIPT_DIR/probe.py\" ] && [ -n \"$P_W\" ] && [ -n \"$P_H\" ]; then\n PDUR=()\n [ -n \"$P_TOTAL\" ] && [ -n \"$P_TLFPS\" ] && PDUR=(--frames $(( (P_TOTAL * FPS + P_TLFPS / 2) / P_TLFPS )))\n AMODE=any; [ -n \"$AUDIO\" ] && AMODE=required\n if python3 \"$SCRIPT_DIR/probe.py\" \"$OUT\" --spec \"${P_W}x${P_H}@${FPS}\" ${PDUR[@]+\"${PDUR[@]}\"} --audio \"$AMODE\" \\\n --json \"$QA/probe.json\" --quiet > /dev/null; then\n step probe pass; log \"probe: pass ($QA/probe.json)\"\n else\n fail_step probe \"probe.py failed; see $QA/probe.json\"\n fi\nelse\n step probe skipped\nfi\n\n[ \"$KEEP\" = 1 ] && log \"kept frames in $WORK/frames\"\nreport true\nlog \"done: $OUT\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":13911,"content_sha256":"1f3324a4ea7961145ead903133ec1ea0acf176c251b576783a28484781240c4e"},{"filename":"scripts/layout-audit.sh","content":"#!/usr/bin/env bash\n# layout-audit.sh: render audit stills of a composition and check every text box.\n#\n# Renders the chosen frames with --props '{\"audit\":true}'. The starter's \u003cAudit> (src/lib/Audit.tsx)\n# measures every element tagged data-text=\"\u003cid>\" and logs one CINETIC_AUDIT JSON line per frame; this\n# script collects them and fails on text outside the safe zone, text boxes that overlap, readable\n# text under 22 px (at 1080p; 32 px in a 9:16 frame), text covered by a moving element tagged\n# data-mover (sampled with elementsFromPoint, so z-order counts), more film words readable at once\n# than the pace allows (8 when calm; product strings tagged data-ui count apart) and, with --speed,\n# readable text that moves too fast: 20 px/f while it enters or travels (motion blur turns it into\n# a double image), 3 px/f while it is being read. The audit stills (boxes drawn, safe zone dashed)\n# are kept.\n#\n# Usage (from the project root):\n# bash scripts/layout-audit.sh \u003cComp> [--frames 120,240 | --cues | --copy] [--props '{\"k\":1}']\n# [--speed | --no-speed] [--max-text-speed 20] [--max-read-speed 3]\n# [--safe feed9x16 | --safe 270,120,420,64 | --safe 96,64]\n# [--entry src/index.ts] [--json out/qa/layout-\u003cComp>.json]\n# [--out out/qa/layout-\u003cComp>] [--concurrency 2]\n# frames --copy (default): each COPY entry's resolved frame, mid-hold and last frame before it leaves\n# --cues: every CUE frame plus each COPY resolved frame\n# --frames: an explicit comma list\n# Comp ActN reads frames relative to the Nth entry of ACT; any other composition (Film,\n# Film9x16, ...) uses absolute timeline frames. The composition must accept an `audit` prop\n# (and a `safe` prop it hands to \u003cAudit>, as the starter's Film and acts do).\n# speed also renders f+1 for each audited frame f (except the composition's last) and fails a\n# readable text box (effective opacity >= 0.5, at least the minimum size) whose centre\n# moves more than --max-text-speed px/f (default 20, in px of a 1080 px tall frame, so\n# 9:16 and 1:1 compare fairly). Boxes match by data-text id: give each line its own.\n# Inside a box's reading window the limit is --max-read-speed (default 3 px/f): the\n# window is the timeline's READ for the COPY entry whose id is the box's data-text id (or\n# its part before the first '.'), and for a fragment of a build chain (fx/Chain) that\n# is not the last, its own read hold plus the full line's read after the last fragment\n# resolves: in between, the line may glide to re-centre at in-flight speed.\n# On by default with --cues (--no-speed turns it off); opt in with --copy or --frames,\n# where it doubles the stills. The composition's length comes from src/timeline.ts.\n# safe a preset from src/lib/safe.ts (feed9x16, feed9x16Strict, square1x1, portrait4x5,\n# wide16x9, title), insets top,right,bottom,left in px, the older symmetric x,y, or a\n# JSON object. Default: the composition's own `safe` prop, else the preset for its\n# aspect (9:16 -> feed9x16). The audit also fails text painted under a data-mover.\n# Exit codes: 0 no issues, 1 issues found or the audit did not run, 2 bad arguments.\nset -euo pipefail\n\nusage() { sed -n '2,/^set -euo/p' \"$0\" | sed '$d' | sed 's/^# \\{0,1\\}//'; }\ndie() { echo \"layout-audit: $*\" >&2; exit 2; }\n\nCOMP=\"\"; MODE=copy; FRAMES=\"\"; PROPS=\"{}\"; SAFE=\"\"; ENTRY=src/index.ts; JSON=\"\"; OUTDIR=\"\"; CONC=\"${CONCURRENCY:-2}\"\nSPEED=\"\"; MAXV=20; MAXR=3\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n -h|--help) usage; exit 0 ;;\n --frames) MODE=frames; FRAMES=\"${2:?--frames needs a list}\"; shift 2 ;;\n --cues) MODE=cues; shift ;;\n --copy) MODE=copy; shift ;;\n --speed) SPEED=1; shift ;;\n --no-speed) SPEED=0; shift ;;\n --max-text-speed) MAXV=\"${2:?--max-text-speed needs px/f}\"; SPEED=\"${SPEED:-1}\"; shift 2 ;;\n --max-read-speed) MAXR=\"${2:?--max-read-speed needs px/f}\"; SPEED=\"${SPEED:-1}\"; shift 2 ;;\n --props) PROPS=\"${2:?--props needs JSON}\"; shift 2 ;;\n --safe) SAFE=\"${2:?--safe needs a preset, insets or JSON}\"; shift 2 ;;\n --entry) ENTRY=\"${2:?}\"; shift 2 ;;\n --json) JSON=\"${2:?}\"; shift 2 ;;\n --out) OUTDIR=\"${2:?}\"; shift 2 ;;\n --concurrency) CONC=\"${2:?}\"; shift 2 ;;\n -*) die \"unknown option $1 (see --help)\" ;;\n *) [[ -z \"$COMP\" ]] || die \"one composition only\"; COMP=\"$1\"; shift ;;\n esac\ndone\n[[ -n \"$COMP\" ]] || { usage; die \"missing \u003cComp>\"; }\nnode -e \"process.exit(Number(process.argv[1]) > 0 ? 0 : 1)\" \"$MAXV\" || die \"--max-text-speed must be a positive number of px/f\"\nnode -e \"process.exit(Number(process.argv[1]) > 0 ? 0 : 1)\" \"$MAXR\" || die \"--max-read-speed must be a positive number of px/f\"\n[[ -n \"$SPEED\" ]] || { [[ \"$MODE\" == cues ]] && SPEED=1 || SPEED=0; }\n\n# Run from the project root: the current directory if it holds the entry, else this script's parent.\nif [[ ! -f \"$ENTRY\" ]]; then cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\"; fi\n[[ -f \"$ENTRY\" ]] || die \"entry $ENTRY not found (run from the project root or pass --entry)\"\nJSON=\"${JSON:-out/qa/layout-$COMP.json}\"\nOUTDIR=\"${OUTDIR:-out/qa/layout-$COMP}\"\nmkdir -p \"$(dirname \"$JSON\")\"\nTMP=$(mktemp -d \"${TMPDIR:-/tmp}/layout-audit-XXXXXX\")\ntrap 'rm -rf \"$TMP\"' EXIT\n\n# Frame list and the composition's length from the timeline (tsx loads src/timeline.ts directly).\nif [[ \"$MODE\" != frames || \"$SPEED\" == 1 ]]; then\n TL=\"$(dirname \"$ENTRY\")/timeline.ts\"\n [[ -f \"$TL\" ]] || die \"$TL not found; pass --frames (and --no-speed: --speed reads the composition's length from it)\"\n cat > \"$TMP/frames.ts\" \u003c\u003cEOF\nimport * as T from '$(cd \"$(dirname \"$TL\")\" && pwd)/timeline.ts';\nconst mode = '$MODE';\nconst comp = '$COMP';\nconst acts = Object.values(T.ACT as Record\u003cstring, { from: number; dur: number }>);\nconst m = /^Act(\\d+)$/.exec(comp);\nconst act = m ? acts[Number(m[1]) - 1] : null;\nif (m && !act) { console.error('no ACT entry #' + m[1]); process.exit(3); }\nconst [lo, hi] = act ? [act.from, act.from + act.dur] : [0, T.TOTAL as number];\nconst out = new Set\u003cnumber>();\ntype C = { id: string; text: string; in: number; resolved: number; out?: number; chain?: string; mode?: string };\nconst copy = (T.COPY ?? []) as C[];\nfor (const c of copy) {\n const end = Math.min(c.out ?? T.TOTAL, T.TOTAL) - 1;\n out.add(c.resolved);\n if (mode === 'copy') { out.add(Math.floor((c.resolved + end) / 2)); out.add(end); }\n}\nif (mode === 'cues') for (const v of Object.values(T.CUE as Record\u003cstring, number | number[]>)) for (const f of [v].flat()) out.add(f);\nconst frames = [...out].filter((f) => f >= lo && f \u003c hi).map((f) => Math.round(f - lo)).sort((a, b) => a - b);\n// Reading windows per COPY id (composition frames): the timeline's READ (src/lib/pace.ts\n// readingWindows(), the rule every check shares), else the same rule computed here: from resolved\n// for at most readMax, an earlier fragment of a chain only for its own hold, a build line once\n// more after its last fragment.\nconst P = ((T as any).PACE ?? {})[(T as any).PACE_NAME ?? ''] ?? { holdBase: 36, holdPerWord: 6, holdMin: 42, readMax: 120 };\nconst s60 = (T.FPS as number) / 60;\nconst words = (t: string) => t.split(/\\s+/).filter(Boolean).length;\nconst hold = (n: number) => Math.round(Math.max(P.holdMin, P.holdBase + P.holdPerWord * n) * s60);\nconst cap = Math.round((P.readMax ?? 120) * s60);\nconst READ = (T as any).READ as Record\u003cstring, [number, number][]> | undefined;\nconst windows: Record\u003cstring, [number, number][]> = {};\nfor (const c of copy) {\n let w: [number, number][];\n if (READ && Array.isArray(READ[c.id])) w = READ[c.id];\n else {\n const end = Math.min(c.out ?? T.TOTAL, T.TOTAL);\n const chain = c.chain ? copy.filter((x) => x.chain === c.chain).sort((a, b) => a.in - b.in) : [c];\n const last = chain[chain.length - 1];\n const next = chain[chain.indexOf(c) + 1];\n const own: [number, number] = next ? [c.resolved, Math.min(next.in, c.resolved + hold(words(c.text)), end)] : [c.resolved, Math.min(end, c.resolved + cap)];\n const line: [number, number] | null = next && (c.mode ?? 'build') === 'build' ? [last.resolved, Math.min(last.out ?? T.TOTAL, last.resolved + cap)] : null;\n w = line ? [own, line] : [own];\n }\n windows[c.id] = w.map(([a, b]) => [a - lo, b - lo]);\n}\nconsole.log([...new Set(frames)].join(','));\nconsole.log(hi - lo);\nconsole.log(JSON.stringify(windows));\nEOF\n LIST=$(npx tsx \"$TMP/frames.ts\") || die \"could not read frames from $TL\"\n LEN=$(sed -n 2p \u003c\u003c\u003c \"$LIST\")\n WINDOWS=$(sed -n 3p \u003c\u003c\u003c \"$LIST\")\n if [[ \"$MODE\" != frames ]]; then\n FRAMES=$(head -n 1 \u003c\u003c\u003c \"$LIST\")\n [[ -n \"$FRAMES\" ]] || die \"no COPY/CUE frames fall inside $COMP; pass --frames\"\n fi\nfi\nFRAMES=$(node -e \"const f=[...new Set(process.argv[1].split(',').map(Number))].filter(Number.isInteger).sort((a,b)=>a-b);if(!f.length)process.exit(1);console.log(f.join(','))\" \"$FRAMES\") || die \"bad --frames list\"\nALLPROPS=$(node -e \"try{const p=JSON.parse(process.argv[1]);p.audit=true;console.log(JSON.stringify(p))}catch(e){process.exit(1)}\" \"$PROPS\") || die \"--props is not valid JSON\"\nif [[ -n \"$SAFE\" ]]; then\n # preset name | t,r,b,l | x,y | JSON -> props.safe (validated again by src/lib/safe.ts at render)\n ALLPROPS=$(node -e '\n const [props, s] = process.argv.slice(1); const p = JSON.parse(props); let v;\n if (/^[A-Za-z][A-Za-z0-9]*$/.test(s)) v = s;\n else if (/^\\s*\\{/.test(s)) v = JSON.parse(s);\n else { const n = s.split(\",\").map(Number); if (n.some((x) => !(x >= 0))) process.exit(1);\n if (n.length === 4) v = { top: n[0], right: n[1], bottom: n[2], left: n[3] };\n else if (n.length === 2) v = { x: n[0], y: n[1] }; else process.exit(1); }\n p.safe = v; console.log(JSON.stringify(p));' \"$ALLPROPS\" \"$SAFE\") || die \"--safe must be a preset name, top,right,bottom,left, x,y or a JSON object\"\nfi\n\n# --speed: render f+1 beside each audited frame f (none past the composition's last frame).\nRENDER=\"$FRAMES\"; SPEEDMAX=0; WINDOWS=\"${WINDOWS:-}\"\nif [[ \"$SPEED\" == 1 ]]; then\n RENDER=$(node -e \"const n=Number(process.argv[2]);console.log([...new Set(process.argv[1].split(',').map(Number).flatMap((f)=>f+1\u003cn?[f,f+1]:[f]))].sort((a,b)=>a-b).join(','))\" \"$FRAMES\" \"$LEN\")\n SPEEDMAX=\"$MAXV\"\nfi\n\necho \"layout-audit: $COMP frames $FRAMES$([[ \"$SPEED\" == 1 ]] && echo \" (+ f+1 of each for text speed, max $MAXV px/f, $MAXR while read)\")\"\nrm -rf \"$OUTDIR\"; mkdir -p \"$OUTDIR\"\nif ! npx remotion render \"$ENTRY\" \"$COMP\" \"$OUTDIR\" --sequence --frames=\"$RENDER\" --props=\"$ALLPROPS\" \\\n --image-format=jpeg --concurrency=\"$CONC\" --log=verbose > \"$TMP/render.log\" 2>&1; then\n grep -m1 -E \"safe: (unknown preset|[a-z]+ must be)\" \"$TMP/render.log\" >&2 || tail -30 \"$TMP/render.log\" >&2\n echo \"layout-audit: render failed\" >&2; exit 1\nfi\n\nnode - \"$TMP/render.log\" \"$JSON\" \"$COMP\" \"$FRAMES\" \"$OUTDIR\" \"$SPEEDMAX\" \"${LEN:-0}\" \"$MAXR\" \"$WINDOWS\" \u003c\u003c'EOF'\nconst fs = require('fs');\nconst [log, jsonPath, comp, framesArg, outDir, maxArg, lenArg, readArg, windowsArg] = process.argv.slice(2);\nconst want = framesArg.split(',').map(Number);\nconst maxV = Number(maxArg); // text-speed limit, px/f in a 1080 px tall frame; 0: the check is off\nconst maxR = Number(readArg); // the limit while the text is being read\nconst windows = JSON.parse(windowsArg || '{}'); // COPY id -> [[from, to), ...] reading windows (composition frames)\nconst readingAt = (id, f) => {\n const w = windows[id] ?? windows[String(id).split('.')[0]];\n return !!w && w.some(([a, b]) => f >= a && f \u003c b);\n};\nconst pairs = maxV > 0 ? want.filter((f) => f + 1 \u003c Number(lenArg)) : []; // f -> f+1\nconst byFrame = new Map();\nfor (const line of fs.readFileSync(log, 'utf8').split('\\n')) {\n const TAG = 'CINETIC_AUDIT ';\n const i = line.indexOf(TAG);\n if (i \u003c 0) continue;\n try { const d = JSON.parse(line.slice(i + TAG.length)); byFrame.set(d.frame, d); } catch { /* truncated line */ }\n}\nconst missing = [...new Set([...want, ...pairs.map((f) => f + 1)])].filter((f) => !byFrame.has(f));\nconst audited = want.filter((f) => byFrame.has(f)).map((f) => byFrame.get(f)); // f+1 frames only feed the speed\nconst issues = [];\nfor (const d of audited) for (const x of d.issues) issues.push({ frame: d.frame, ...x });\n// Text speed: each box's centre from f to the nearest box with its id on f+1, in px of a 1080 px\n// tall frame. Readable: effective opacity >= 0.5 and at least the minimum size.\nlet fastest = null;\nlet fastestRead = null;\nfor (const f of pairs) {\n const a = byFrame.get(f), b = byFrame.get(f + 1);\n if (!a || !b) continue;\n const k = 1080 / a.height;\n for (const box of a.boxes) {\n const cx = box.x + box.w / 2, cy = box.y + box.h / 2;\n const d = Math.min(...b.boxes.filter((n) => n.id === box.id).map((n) => Math.hypot(n.x + n.w / 2 - cx, n.y + n.h / 2 - cy)));\n if (!Number.isFinite(d)) continue; // gone (or faded out) on f+1\n box.speed = Math.round(d * k * 10) / 10;\n if (box.opacity \u003c 0.5 || box.px \u003c (a.minPx ?? 0)) continue;\n if (!fastest || box.speed > fastest.speed) fastest = { frame: f, id: box.id, speed: box.speed };\n const read = readingAt(box.id, f);\n if (read && (!fastestRead || box.speed > fastestRead.speed)) fastestRead = { frame: f, id: box.id, speed: box.speed };\n const limit = read ? maxR : maxV;\n if (box.speed \u003c= limit) continue;\n const at = k === 1 ? '' : ` (${Math.round(d * 10) / 10} px/f in ${a.width}x${a.height}, scaled to 1080 px tall)`;\n issues.push({ frame: f, kind: read ? 'read-speed' : 'text-speed', id: box.id,\n detail: read\n ? `${box.speed} px/f > ${maxR}${at} while it is being read: hold it near still (a drift under 1 px/f) until the read is done`\n : `${box.speed} px/f > ${maxV}${at}: slow it under ${maxV} px/f, or fade its text out for the flight and resolve it on landing`, speed: box.speed });\n }\n}\nissues.sort((p, q) => p.frame - q.frame);\nconst report = {\n tool: 'layout-audit', comp, frames: want, audited: audited.length, missing, stills: outDir,\n pass: issues.length === 0 && missing.length === 0 && audited.length > 0,\n issues,\n speed: maxV > 0 ? { max: maxV, read: maxR, pairs: pairs.length, fastest, fastestRead } : null,\n words: audited.reduce((m, d) => (d.words !== undefined && (!m || d.words > m.max) ? { max: d.words, frame: d.frame, ui: d.uiWords ?? 0 } : m), null),\n zone: audited[0]?.zone, safe: audited[0]?.safe,\n boxes: audited.map((d) => ({ frame: d.frame, safe: d.safe, boxes: d.boxes })),\n};\nfs.writeFileSync(jsonPath, JSON.stringify(report, null, 1) + '\\n');\nfor (const x of issues) console.log(` f${x.frame} ${x.kind} ${x.id} ${x.detail}`);\nif (!byFrame.size) console.log(` no CINETIC_AUDIT output: does ${comp} accept the audit prop and render \u003cAudit/> inside \u003cFontGate>?`);\nelse if (missing.length) console.log(` no audit line for frames ${missing.join(',')}`);\nconst n = audited.reduce((s, d) => s + d.boxes.length, 0);\nconst z = report.safe ? ` in ${report.zone} (top ${report.safe.top}, right ${report.safe.right}, bottom ${report.safe.bottom}, left ${report.safe.left})` : '';\nconst v = maxV > 0 ? `, fastest readable text ${fastest ? `${fastest.speed} px/f (${fastest.id} f${fastest.frame})` : 'not measured'}` +\n (fastestRead ? `, ${fastestRead.speed} px/f while read (${fastestRead.id} f${fastestRead.frame})` : '') : '';\nconst wv = report.words ? `, at most ${report.words.max} film words readable (f${report.words.frame})` : '';\nconsole.log(`layout-audit: ${audited.length}/${want.length} frames, ${n} text boxes${z}${v}${wv}, ${issues.length} issues -> ${report.pass ? 'pass' : 'FAIL'} (${jsonPath}, stills in ${outDir})`);\nprocess.exit(report.pass ? 0 : 1);\nEOF\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":15771,"content_sha256":"e2704470bfd587593947f04dfa5ac44753b99b64f1e44e8e8590939dd46f6310"},{"filename":"scripts/lint-film.mjs","content":"#!/usr/bin/env node\n/**\n * lint-film.mjs: static bans for a cinetic film (Remotion src/ or a HyperFrames project).\n *\n * Catches the code patterns that render wrong or read as generated before anything is rendered:\n * css-animation CSS transition/animation, @keyframes, animate-* classes (they do not render\n * frame-by-frame: everything must be a function of the frame)\n * nondeterminism Math.random, Date.now, performance.now, new Date() (renders must repeat)\n * will-change-blur willChange in a file that animates filter: blur() (stale ghost frames)\n * frame-left-top left/top/right/bottom driven by the frame (pixel-snapped stairs; use transform)\n * raw-easing Easing.bezier / cubic-bezier / Easing.spring|elastic|bounce|back outside lib/anim.ts\n * spring-config inline {damping, stiffness} spring configs outside lib/anim.ts (use SPR tokens)\n * interpolate-clamp interpolate() without extrapolateLeft AND extrapolateRight 'clamp' (it extends)\n * off-token-color hex colours not declared in the tokens file; other colour literals (warning)\n * gradient-low-span opaque gradients spanning \u003c 30 code values (they band in 8 bit; use a dithered PNG)\n * premount premountFor is a no-op in renders and ignored with layout=\"none\" (warning)\n * inbrowser-blur CameraMotionBlur / HtmlInCanvasMotionBlur / Trail (8-bit accumulation; use render.sh --blur)\n * remote-font font packages or stylesheets fetched over the network (ship local variable fonts)\n * hf-* HyperFrames: gsap .from(), repeat: -1, \u003cbr>, \u003caudio> without id\n * placeholder a `cinetic:placeholder` marker is still in the source: the starter's stand-in\n * palette, typeface, mark or name was never replaced (error; the film would look\n * like every other starter film). Invent the brand or apply the user's, choose the\n * family on purpose, then delete the marker. Markers in HTML/CSS comments count.\n *\n * Text flow (warnings; --strict makes them fail). Calm films arrive as fragments: copy that escapes\n * COPY escapes every timing check, and springs or per-letter tricks on type read as gimmicks.\n * copy-literal on-screen text of 2+ words written in an act (JSX text, a {'string'} child,\n * a text/label/title/caption/line prop, a words={['...']} list, or one word per\n * element in sibling elements that together read 2+ words) instead of read from\n * COPY through copy() / chainOf(): grid-check never sees its words, hold or\n * fragments.\n * Acts are files under an acts/ folder or named Act*, Scene*, Shot*\n * text-spring an overshooting spring (spr() with SPR.snap/pop/land/word/detent/micro, or\n * spr()'s snap default, or spring() with such a config or none) driving an\n * element that carries data-text or a text component (Words, MaskRise, Chain,\n * TypeOn, Scramble, Roll), followed through the declarations by name (object\n * keys, strings and .property names do not carry it); or any element given an\n * overshooting preset as a cfg/config/spring prop whose children are text (JSX\n * text, a string, copy(...)): type eases in or rides SPR.calm. Which presets\n * overshoot is read from the project's lib/anim.ts (damping \u003c 2*sqrt(k*m))\n * text-pace in a calm film (PACE_NAME 'calm' in the nearest timeline.ts), \u003cWords> or\n * \u003cMaskRise> given stagger \u003c 8, dur \u003c 24 or outDur \u003c 18 frames (60 fps):\n * the crisp tempo inside a calm film (pass pace={PACE.crisp} to mean it)\n * typing-rate \u003cTypeOn cps> above 30, or a hand-rolled type-on (.slice(0, n) / substring\n * with n growing with the frame, followed through the names it is built from)\n * faster than 30 characters a second\n * per-letter a string split into letters (.split(''), or [...x] / Array.from(x) where x is\n * a string: declared from a string or copy(), typed string, a .text, or named\n * text/word/label/title/...) and mapped into animated spans outside fx/ and\n * lib/: animate words or whole fragments; the one hero word may keep it\n * (// lint-ok: hero word)\n *\n * Hard bans (SKILL.md \"Hard bans\"): errors whenever you invent the look.\n * banned-color any colour (tokens, :root palette, literals, CSS names) in a banned OKLCH region:\n * orange/amber 31 \u003c= h \u003c 96, C >= 0.05 (orange, amber, gold, brass, brown, coral)\n * beige/cream 31 \u003c= h \u003c 118, L >= 0.70, 0.007 \u003c= C \u003c 0.10 (beige, cream, tan, sand)\n * purple 270 \u003c= h \u003c 340, C >= 0.02 (purple, violet, indigo, lavender, magenta)\n * neon C >= 0.27; L >= 0.85 and C >= 0.15; L >= 0.80 and C >= 0.18\n * (yellows, 96 \u003c= h \u003c 118: only L >= 0.91 and C >= 0.15)\n * Reds (h >= 340 or h \u003c 31, e.g. #EC2A3A at h 24), greens, teals, blues and yellows pass.\n * serif serif faces: @fontsource packages, font-family values, FONT/FACES strings,\n * the generic `serif` / `ui-serif`\n * italic italic or oblique styles, \u003ci>/\u003cem>, and skew on text (a faux italic; skew\n * on a style that also sets type is an error, any other skew a warning)\n * glass backdrop-filter (glassmorphism)\n * glow a white or coloured box-/text-/drop-shadow with blur, or a coloured radial\n * falloff to transparent (glows, halos, bloom); black shadows are fine\n * gradient-multihue gradients across several hues (the generated look)\n * emoji emoji and sparkle glyphs in strings or markup\n * stock-decoration icon, confetti and particle libraries (stock icons, sparkles, confetti)\n *\n * Brand-supplied escape: when the user's brand supplies a banned thing (their orange, their serif\n * wordmark), write `cinetic:brand-supplied \u003cwhat>` in a comment and record it in BRIEF.md.\n * - in a file's leading comment block (for HTML: an \u003c!-- --> comment before \u003cbody>/\u003ctemplate>),\n * it covers the hard-ban rules in the whole file;\n * - anywhere else, it covers the hard-ban rules on its own line and the next one.\n * A marker with nothing after it is an error: say what the brand supplied.\n *\n * Palette: hex colours must appear in the tokens file (Remotion: src/brand/tokens.ts). In a\n * HyperFrames project with no tokens file, the custom properties in the HTML's `:root { }` blocks\n * are the palette, and motion.js is the easing module (raw beziers are allowed there).\n *\n * Suppress one finding with a reason on the same or previous line: // lint-ok: \u003creason>\n * (or \u003c!-- lint-ok: reason --> in HTML). Disable whole rules for a run with --allow rule,...\n *\n * Usage:\n * node scripts/lint-film.mjs [src] [--tokens src/brand/tokens.ts] [--json] [--strict] [--allow rule,...]\n * src file or directory to scan (default: src, or . if there is no src)\n * --tokens file whose hex colours are the palette (default: \u003csrc>/brand/tokens.ts if present,\n * else the :root custom properties of the scanned HTML/CSS)\n * --json print a JSON report instead of text\n * --strict warnings fail too\n * Exit codes: 0 clean (warnings allowed unless --strict), 1 errors found, 2 bad arguments.\n * Tests (in the skill, not copied into films): node scripts/test/lint-film.test.mjs\n */\nimport { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';\nimport { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';\n\n// ---------------------------------------------------------------------------------- arguments\nconst argv = process.argv.slice(2);\nconst opt = { src: null, tokens: null, json: false, strict: false, allow: new Set() };\nfor (let i = 0; i \u003c argv.length; i++) {\n const a = argv[i];\n if (a === '--json') opt.json = true;\n else if (a === '--strict') opt.strict = true;\n else if (a === '--tokens') opt.tokens = argv[++i];\n else if (a === '--allow') (argv[++i] || '').split(',').filter(Boolean).forEach((r) => opt.allow.add(r.trim()));\n else if (a === '-h' || a === '--help') {\n console.log(readFileSync(new URL(import.meta.url)).toString().split('*/')[0].replace(/^#!.*\\n\\/\\*\\*?/, '').replace(/^ \\* ?/gm, ''));\n process.exit(0);\n } else if (a.startsWith('-')) {\n console.error(`lint-film: unknown option ${a}`);\n process.exit(2);\n } else if (!opt.src) opt.src = a;\n else {\n console.error(`lint-film: one path only (got ${opt.src} and ${a})`);\n process.exit(2);\n }\n}\nconst root = resolve(opt.src ?? (existsSync('src') ? 'src' : '.'));\nif (!existsSync(root)) {\n console.error(`lint-film: ${root} does not exist`);\n process.exit(2);\n}\n\n// ---------------------------------------------------------------------------------- files\n// vendor: third-party runtimes (a HyperFrames project's gsap.min.js); scripts: the cinetic tooling itself\nconst SKIP_DIRS = new Set(['node_modules', 'out', 'build', 'dist', '.git', '.remotion', 'renders', '__pycache__', 'vendor', 'scripts']);\nconst EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html', '.htm']);\nconst files = [];\nconst walk = (p) => {\n const st = statSync(p);\n if (st.isDirectory()) {\n if (SKIP_DIRS.has(basename(p))) return;\n for (const e of readdirSync(p)) walk(join(p, e));\n } else if (EXTS.has(extname(p)) && !p.endsWith('.d.ts')) files.push(p);\n};\nwalk(root);\n\nconst tokensPath = opt.tokens\n ? resolve(opt.tokens)\n : [join(root, 'brand', 'tokens.ts'), join(root, 'src', 'brand', 'tokens.ts')].find((p) => existsSync(p)) ?? null;\nif (opt.tokens && !existsSync(tokensPath)) {\n console.error(`lint-film: tokens file ${opt.tokens} not found`);\n process.exit(2);\n}\n// a tokens file outside the scanned tree is still checked (its colours and faces are the brand)\nif (tokensPath && !files.some((f) => resolve(f) === tokensPath)) files.push(tokensPath);\n\n// ---------------------------------------------------------------------------------- colour helpers\nconst normHex = (h) => {\n let x = h.replace('#', '').toLowerCase();\n if (x.length === 3 || x.length === 4) x = x.split('').map((c) => c + c).join('');\n return '#' + x.slice(0, 6);\n};\nconst HEX_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\\b/g;\nconst tokenHex = new Set();\nconst tokenByName = new Map();\nif (tokensPath) {\n const t = readFileSync(tokensPath, 'utf8');\n for (const m of t.matchAll(HEX_RE)) tokenHex.add(normHex(m[0]));\n for (const m of t.matchAll(/([A-Za-z_$][\\w$-]*)\\s*:\\s*['\"](#[0-9a-fA-F]{3,8})['\"]/g)) tokenByName.set(m[1], normHex(m[2]));\n for (const m of t.matchAll(/--([\\w-]+)\\s*:\\s*(#[0-9a-fA-F]{3,8})/g)) tokenByName.set(m[1], normHex(m[2]));\n}\n// HyperFrames (or any project without a tokens file): the :root custom properties are the palette.\nconst ROOT_BLOCK_RE = /:root\\s*\\{[^}]*\\}/g;\nlet rootTokens = null; // file(s) the :root palette came from\nif (!tokensPath) {\n const from = [];\n for (const f of files.filter((x) => /\\.(html?|css)$/.test(x))) {\n const src = readFileSync(f, 'utf8');\n for (const block of src.match(ROOT_BLOCK_RE) ?? []) {\n let found = false;\n for (const m of block.matchAll(/--([\\w-]+)\\s*:\\s*(#[0-9a-fA-F]{3,8})\\b/g)) {\n tokenHex.add(normHex(m[2]));\n tokenByName.set(m[1], normHex(m[2]));\n found = true;\n }\n if (found && !from.includes(f)) from.push(f);\n }\n }\n if (from.length) rootTokens = from.map((f) => relative(process.cwd(), f) || f).join(', ') + ' :root';\n}\nconst paletteName = tokensPath ? relative(process.cwd(), tokensPath) : rootTokens;\nconst rgbOf = (hex) => {\n const x = normHex(hex).slice(1);\n return [0, 2, 4].map((i) => parseInt(x.slice(i, i + 2), 16));\n};\n// OKLCH (approximate, enough to tell hues apart)\nconst oklch = ([r, g, b]) => {\n const lin = (c) => ((c /= 255) \u003c= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);\n const [R, G, B] = [lin(r), lin(g), lin(b)];\n const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);\n const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B);\n const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B);\n const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;\n const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;\n const Bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;\n return { L, C: Math.hypot(A, Bb), h: ((Math.atan2(Bb, A) * 180) / Math.PI + 360) % 360 };\n};\nconst luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;\n\n// ---------------------------------------------------------------------------------- hard bans\n// The banned OKLCH regions (SKILL.md \"Hard bans\"; references/brand-and-color.md §8). palette.py\n// carries the same numbers: change them in both places.\nconst YELLOW = [96, 118];\nconst bannedRegion = ({ L, C, h }) => {\n const neon = h >= YELLOW[0] && h \u003c YELLOW[1] ? L >= 0.91 && C >= 0.15 : (L >= 0.85 && C >= 0.15) || (L >= 0.8 && C >= 0.18);\n if (neon || C >= 0.27) return 'neon (very bright and saturated)';\n if (h >= 31 && h \u003c 118 && L >= 0.7 && C >= 0.007 && C \u003c 0.1) return 'beige/cream (warm, light, low chroma: beige, cream, tan, sand)';\n if (h >= 31 && h \u003c 96 && C >= 0.05) return 'orange/amber (orange, amber, gold, brass, brown, coral)';\n if (h >= 270 && h \u003c 340 && C >= 0.02) return 'purple/violet/indigo';\n return null;\n};\n// CSS colour names in or near the banned regions (hex values from the CSS spec).\nconst NAMED = {\n orange: '#ffa500', darkorange: '#ff8c00', orangered: '#ff4500', coral: '#ff7f50', tomato: '#ff6347',\n lightsalmon: '#ffa07a', darksalmon: '#e9967a', sandybrown: '#f4a460', chocolate: '#d2691e', sienna: '#a0522d',\n saddlebrown: '#8b4513', peru: '#cd853f', burlywood: '#deb887', tan: '#d2b48c', wheat: '#f5deb3',\n beige: '#f5f5dc', bisque: '#ffe4c4', blanchedalmond: '#ffebcd', cornsilk: '#fff8dc', antiquewhite: '#faebd7',\n linen: '#faf0e6', oldlace: '#fdf5e6', papayawhip: '#ffefd5', moccasin: '#ffe4b5', navajowhite: '#ffdead',\n peachpuff: '#ffdab9', floralwhite: '#fffaf0', ivory: '#fffff0', lemonchiffon: '#fffacd', khaki: '#f0e68c',\n darkkhaki: '#bdb76b', palegoldenrod: '#eee8aa', gold: '#ffd700', goldenrod: '#daa520', darkgoldenrod: '#b8860b',\n purple: '#800080', violet: '#ee82ee', indigo: '#4b0082', magenta: '#ff00ff', fuchsia: '#ff00ff', orchid: '#da70d6',\n plum: '#dda0dd', thistle: '#d8bfd8', lavender: '#e6e6fa', mediumpurple: '#9370db', rebeccapurple: '#663399',\n blueviolet: '#8a2be2', darkviolet: '#9400d3', darkorchid: '#9932cc', mediumorchid: '#ba55d3', darkmagenta: '#8b008b',\n slateblue: '#6a5acd', darkslateblue: '#483d8b', mediumslateblue: '#7b68ee', lime: '#00ff00', aqua: '#00ffff',\n cyan: '#00ffff', chartreuse: '#7fff00', lawngreen: '#7cfc00', springgreen: '#00ff7f', yellow: '#ffff00',\n};\n/** {rgb, a} for one colour token: hex, rgb()/rgba(), oklch(), a CSS name; null if unknown. */\nconst colorOf = (tok) => {\n const t = tok.trim().toLowerCase();\n if (/^#[0-9a-f]{3,8}$/.test(t)) {\n const x = t.slice(1);\n const a = x.length === 8 ? parseInt(x.slice(6), 16) / 255 : x.length === 4 ? parseInt(x[3] + x[3], 16) / 255 : 1;\n return { rgb: rgbOf(t), a };\n }\n let m = t.match(/^rgba?\\(([^)]*)\\)$/);\n if (m) {\n const v = m[1].split(/[\\s,/]+/).filter(Boolean).map((x) => (x.endsWith('%') ? parseFloat(x) * 2.55 : Number(x)));\n if (v.length \u003c 3 || v.slice(0, 3).some(Number.isNaN)) return null;\n return { rgb: v.slice(0, 3), a: v.length > 3 && Number.isFinite(v[3]) ? v[3] : 1 }; // a runtime alpha counts as opaque\n }\n m = t.match(/^oklch\\(\\s*([\\d.]+)(%?)\\s+([\\d.]+)\\s+([\\d.]+)(?:deg)?\\s*(?:\\/\\s*([\\d.]+%?))?\\s*\\)$/);\n if (m) return { lch: { L: parseFloat(m[1]) / (m[2] ? 100 : 1), C: parseFloat(m[3]), h: parseFloat(m[4]) % 360 }, a: m[5] ? parseFloat(m[5]) / (m[5].endsWith('%') ? 100 : 1) : 1 };\n if (NAMED[t]) return { rgb: rgbOf(NAMED[t]), a: 1 };\n if (t === 'white') return { rgb: [255, 255, 255], a: 1 };\n if (t === 'black') return { rgb: [0, 0, 0], a: 1 };\n return null;\n};\nconst lchOf = (c) => c.lch ?? oklch(c.rgb);\nconst fmtLch = ({ L, C, h }) => `OKLCH ${L.toFixed(2)} ${C.toFixed(3)} ${Math.round(h)}`;\n\n// Serif faces: generic keywords, any family naming \"serif\" (not \"sans\") or \"slab\", and the common\n// serif families by name (system faces and @fontsource packages).\nconst SERIF_NAMES = [\n 'newsreader', 'literata', 'fraunces', 'playfair', 'lora', 'merriweather', 'garamond', 'cormorant', 'crimson',\n 'baskerville', 'caslon', 'bodoni', 'didot', 'georgia', 'times', 'cambria', 'palatino', 'book antiqua', 'spectral',\n 'alegreya', 'cardo', 'vollkorn', 'gelasio', 'besley', 'petrona', 'brygada', 'domine', 'frank ruhl', 'gloock',\n 'piazzolla', 'rasa', 'texturina', 'faustina', 'labrada', 'imbue', 'bitter', 'arvo', 'aleo', 'crete round',\n 'noticia', 'old standard', 'prata', 'abril fatface', 'yeseva', 'cinzel', 'marcellus', 'rozha', 'charter',\n 'iowan', 'hoefler', 'constantia', 'rockwell', 'tinos', 'gentium', 'goudy', 'unna', 'martel', 'lusitana',\n 'manuale', 'eczar', 'amiri', 'mincho', 'young serif', 'instrument serif', 'dm serif', 'bellefair', 'castoro',\n];\nconst isSerifFamily = (name) => {\n const n = name.toLowerCase().replace(/[\"']/g, '').replace(/[-_]+/g, ' ').replace(/\\s+variable$/, '').trim();\n if (!n) return false;\n if (n === 'serif' || n === 'ui serif') return true;\n if (/\\bsans\\b|\\bmono\\b|monospace/.test(n)) return false;\n if (/serif|\\bslab\\b/.test(n)) return true;\n return SERIF_NAMES.some((s) => ` ${n} `.includes(` ${s} `));\n};\n/** Serif families named in a CSS font list or font shorthand ('600 16px \"X Variable\", Georgia, serif'). */\nconst serifIn = (list) =>\n list\n .replace(/^\\s*(?:(?:normal|bold|\\d{3})\\s+)*\\d+(?:\\.\\d+)?(?:px|em|rem|pt)(?:\\/[\\d.]+\\w*)?\\s+/, '')\n .split(',')\n .map((x) => x.trim().replace(/^[\"']|[\"']$/g, ''))\n .filter((x) => isSerifFamily(x));\nconst ICON_LIBS = /^(lucide(-react|-static)?|react-icons(\\/.*)?|@heroicons\\/.*|@tabler\\/icons.*|@phosphor-icons\\/.*|phosphor-react|@mui\\/icons-material.*|@fortawesome\\/.*|react-feather|feather-icons|@radix-ui\\/react-icons|ionicons|@iconify\\/.*|canvas-confetti|react-confetti|react-canvas-confetti|js-confetti|party-js|tsparticles.*|@tsparticles\\/.*|react-tsparticles|particles\\.js)$/;\nconst HARD_BANS = new Set(['banned-color', 'serif', 'italic', 'glass', 'glow', 'gradient-multihue', 'emoji', 'stock-decoration']);\n\n// ---------------------------------------------------------------------------------- source masking\n/**\n * Returns the source with comments blanked (same length, newlines kept) so rules never fire on\n * prose like \"never use Math.random\". Strings are kept. A ' or \" only opens a string if it closes\n * on the same line, so apostrophes in JSX text (\"doesn't\") cannot derail the scan.\n */\nconst stripComments = (src, html) => {\n const out = src.split('');\n const blank = (a, b) => {\n for (let k = a; k \u003c b; k++) if (out[k] !== '\\n') out[k] = ' ';\n };\n let i = 0;\n const n = src.length;\n while (i \u003c n) {\n const c = src[i];\n const d = src[i + 1];\n if (html && src.startsWith('\u003c!--', i)) {\n const e = src.indexOf('-->', i + 4);\n const end = e \u003c 0 ? n : e + 3;\n blank(i, end);\n i = end;\n continue;\n }\n if (c === '/' && d === '/') {\n const e = src.indexOf('\\n', i);\n const end = e \u003c 0 ? n : e;\n blank(i, end);\n i = end;\n continue;\n }\n if (c === '/' && d === '*') {\n const e = src.indexOf('*/', i + 2);\n const end = e \u003c 0 ? n : e + 2;\n blank(i, end);\n i = end;\n continue;\n }\n if (c === \"'\" || c === '\"') {\n const eol = src.indexOf('\\n', i);\n let j = i + 1;\n while (j \u003c n && src[j] !== c && src[j] !== '\\n') j += src[j] === '\\\\' ? 2 : 1;\n if (j \u003c n && src[j] === c && (eol \u003c 0 || j \u003c eol)) {\n i = j + 1;\n continue;\n }\n i++;\n continue;\n }\n if (c === '`') {\n // skip to the closing backtick, descending into ${ } expressions\n let j = i + 1;\n while (j \u003c n && src[j] !== '`') {\n if (src[j] === '\\\\') j += 2;\n else if (src[j] === '

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. -…

&& src[j + 1] === '{') {\n let depth = 1;\n j += 2;\n while (j \u003c n && depth) {\n if (src[j] === '{') depth++;\n else if (src[j] === '}') depth--;\n j++;\n }\n } else j++;\n }\n i = j + 1;\n continue;\n }\n i++;\n }\n return out.join('');\n};\n\n/** Text of a balanced (...) group starting at the '(' index. */\nconst parenGroup = (s, open) => {\n let depth = 0;\n for (let j = open; j \u003c s.length; j++) {\n if (s[j] === '(') depth++;\n else if (s[j] === ')' && --depth === 0) return s.slice(open + 1, j);\n }\n return s.slice(open + 1);\n};\n\n// ---------------------------------------------------------------------------------- film context for the text rules\n// The nearest timeline.ts (or a HyperFrames motion.js) above the scanned path sets the pace; the\n// project's lib/anim.ts (or motion.js) says which SPR presets overshoot.\nconst findUp = (start, names) => {\n let d = statSync(start).isDirectory() ? start : dirname(start);\n for (let up = 0; up \u003c 4; up++) {\n for (const n of names) if (existsSync(join(d, n))) return join(d, n);\n if (dirname(d) === d) break;\n d = dirname(d);\n }\n return null;\n};\nconst timelineFile = findUp(root, ['timeline.ts', join('src', 'timeline.ts'), 'motion.js']);\nconst timelineSrc = timelineFile ? readFileSync(timelineFile, 'utf8') : '';\nconst filmPace = timelineSrc.match(/\\bPACE_NAME\\b[^=\\n]*=\\s*['\"](\\w+)['\"]/)?.[1] ?? null; // null: written before paces (crisp rules)\nconst filmFps = Number(timelineSrc.match(/\\bconst FPS\\s*=\\s*(\\d+)/)?.[1] ?? 60);\nconst OVERSHOOT = new Set(['snap', 'pop', 'land', 'word', 'detent', 'micro']); // the shipped presets, if no anim file is found\n{\n const anim = findUp(root, [join('lib', 'anim.ts'), join('src', 'lib', 'anim.ts'), 'motion.js']);\n const block = anim ? readFileSync(anim, 'utf8').match(/\\bSPR\\s*=\\s*\\{([\\s\\S]*?)\\n\\s*\\}/)?.[1] : null;\n if (block) {\n OVERSHOOT.clear();\n for (const m of block.matchAll(/^\\s*(\\w+)\\s*:\\s*\\{([^}]*)\\}/gm)) {\n const num = (k, d) => Number(m[2].match(new RegExp(`\\\\b${k}\\\\s*:\\\\s*([\\\\d.]+)`))?.[1] ?? d);\n if (num('damping', 10) \u003c 2 * Math.sqrt(num('stiffness', 100) * num('mass', 1)) - 1e-9) OVERSHOOT.add(m[1]);\n }\n }\n}\nconst TEXT_TAGS = /^(Words|MaskRise|Chain|TypeOn|Scramble|Roll)$/;\nconst twoWords = (t) => (t.match(/[A-Za-z][A-Za-z'’-]*/g) ?? []).length >= 2;\n/** Every `const x = ...` declaration of a source as [lhs, rhs, index] (destructuring kept whole). */\nconst declsOf = (code) => [...code.matchAll(/\\b(?:const|let|var)\\s+(\\{[^}]*\\}|\\[[^\\]]*\\]|[A-Za-z_$][\\w$]*)\\s*(?::[^=]+)?=\\s*([^;]+?)(?:;|(?=\\n\\s*(?:const|let|var|return|export|\\})))/gs)].map((m) => [m[1], m[2], m.index]);\n/**\n * The names an expression reads: string literals, the text of template literals (their ${...}\n * expressions stay), object keys ({ badge: ... }) and .property names are not names it reads, so\n * `const V = { badge: 4 }` never takes on the meaning of a variable called badge elsewhere.\n */\nconst namesIn = (expr) => {\n let s = '';\n for (let i = 0; i \u003c expr.length; i++) {\n const c = expr[i];\n if (c === \"'\" || c === '\"') {\n let j = i + 1;\n while (j \u003c expr.length && expr[j] !== c) j += expr[j] === '\\\\' ? 2 : 1;\n s += ' ';\n i = j;\n } else if (c === '`') {\n let j = i + 1;\n while (j \u003c expr.length && expr[j] !== '`') {\n if (expr[j] === '\\\\') j += 2;\n else if (expr[j] === '

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. -…

&& expr[j + 1] === '{') {\n let depth = 1;\n let k = j + 2;\n while (k \u003c expr.length && depth) {\n if (expr[k] === '{') depth++;\n else if (expr[k] === '}') depth--;\n k++;\n }\n s += ' ' + expr.slice(j + 2, k - 1) + ' ';\n j = k;\n } else j++;\n }\n s += ' ';\n i = j;\n } else s += c;\n }\n s = s.replace(/([{,]\\s*)[A-Za-z_$][\\w$]*\\s*:(?!:)/g, '$1').replace(/(?\u003c!\\.)\\.\\s*[A-Za-z_$][\\w$]*/g, ' ');\n return s.match(/[A-Za-z_$][\\w$]*/g) ?? [];\n};\n/** Grows `set` with every name declared from an expression that reads one already in it. */\nconst propagate = (decls, set) => {\n for (let pass = 0; pass \u003c 6; pass++) {\n let grew = false;\n for (const [lhs, rhs] of decls) {\n const ids = namesIn(rhs);\n if (!ids.some((id) => set.has(id))) continue;\n for (const name of lhs.match(/[A-Za-z_$][\\w$]*/g) ?? []) if (!set.has(name)) (set.add(name), (grew = true));\n }\n if (!grew) break;\n }\n return set;\n};\n/** The JSX tag that opens at `i` ('\u003cName ...>'): its name and its full text, braces and strings respected. */\nconst tagAt = (code, i) => {\n const name = code.slice(i + 1).match(/^[A-Za-z][\\w.]*/)?.[0];\n if (!name) return null;\n let depth = 0;\n for (let j = i + 1 + name.length; j \u003c code.length; j++) {\n const c = code[j];\n if (c === '{') depth++;\n else if (c === '}') depth--;\n else if ((c === '\"' || c === \"'\") && depth === 0) {\n const e = code.indexOf(c, j + 1);\n if (e \u003c 0) return null;\n j = e;\n } else if (c === '>' && depth === 0 && code[j - 1] !== '=') return { name, text: code.slice(i, j + 1) };\n else if (c === '\u003c' && depth === 0) return null; // not a tag after all (a comparison or a generic)\n }\n return null;\n};\n\n// ---------------------------------------------------------------------------------- lint\nconst issues = [];\nlet suppressed = 0;\nlet brandSupplied = 0;\nconst BRAND_RE = /cinetic:brand-supplied\\b/;\nconst BRAND_REASON_RE = /cinetic:brand-supplied\\b[\\s:,.;–—-]*[\\w\"'#(]/;\n\nfor (const file of files) {\n const raw = readFileSync(file, 'utf8');\n const ext = extname(file);\n const isHtml = ext === '.html' || ext === '.htm';\n const isCss = ext === '.css';\n const isScript = !isHtml && !isCss;\n const rel = relative(process.cwd(), file) || file;\n const norm = file.split(sep).join('/');\n const isAnim = /(^|\\/)lib\\/anim\\.(ts|js|mjs)$/.test(norm) || /(^|\\/)motion\\.js$/.test(norm);\n const isTokens = tokensPath && resolve(file) === tokensPath;\n const code = isCss ? stripComments(raw, false) : stripComments(raw, isHtml);\n const lines = raw.split('\\n');\n const lineStarts = [0];\n for (let k = 0; k \u003c raw.length; k++) if (raw[k] === '\\n') lineStarts.push(k + 1);\n const pos = (idx) => {\n let lo = 0;\n let hi = lineStarts.length - 1;\n while (lo \u003c hi) {\n const mid = (lo + hi + 1) >> 1;\n if (lineStarts[mid] \u003c= idx) lo = mid;\n else hi = mid - 1;\n }\n return { line: lo + 1, col: idx - lineStarts[lo] + 1 };\n };\n // Brand-supplied escape: a marker in the leading comment block (HTML: before \u003cbody>/\u003ctemplate>)\n // covers the file's hard-ban findings; anywhere else it covers its own line and the next.\n const headEnd = isHtml\n ? (() => {\n const k = raw.search(/\u003c(body|template)\\b/i);\n return k \u003c 0 ? 0 : k;\n })()\n : Math.max(0, code.search(/\\S/));\n const brandFile = isHtml\n ? (raw.slice(0, headEnd).match(/\u003c!--[\\s\\S]*?-->/g) ?? []).some((c) => BRAND_RE.test(c))\n : BRAND_RE.test(raw.slice(0, headEnd));\n const report = (idx, rule, severity, message) => {\n if (opt.allow.has(rule) || [...opt.allow].some((a) => a.endsWith('*') && rule.startsWith(a.slice(0, -1)))) return;\n const { line, col } = pos(idx);\n const here = lines[line - 1] ?? '';\n const prev = lines[line - 2] ?? '';\n if (HARD_BANS.has(rule) && (brandFile || BRAND_RE.test(here) || BRAND_RE.test(prev))) {\n brandSupplied++;\n return;\n }\n if (/lint-ok\\s*:\\s*\\S/.test(here) || /lint-ok\\s*:\\s*\\S/.test(prev)) {\n suppressed++;\n return;\n }\n if (issues.some((x) => x.file === rel && x.line === line && x.rule === rule && x.message === message)) return;\n issues.push({ file: rel, line, col, rule, severity, message, excerpt: here.trim().slice(0, 140) });\n };\n const each = (re, fn) => {\n for (const m of code.matchAll(re)) fn(m, m.index);\n };\n\n for (const m of raw.matchAll(/cinetic:brand-supplied\\b.*/g))\n if (!BRAND_REASON_RE.test(m[0].replace(/\\*\\/|-->/g, '')))\n report(m.index, 'brand-supplied', 'error', 'say what the brand supplied after cinetic:brand-supplied (and record it in BRIEF.md)');\n\n // --- css-animation\n if (!isScript) {\n each(/(?\u003c![\\w-])(transition|animation)(-[a-z-]+)?\\s*:/g, (m, i) =>\n report(i, 'css-animation', 'error', `CSS ${m[1]} runs on wall-clock time, not the render clock; drive it from the frame`),\n );\n } else {\n // a style key with a literal value: transition: 'opacity 0.3s', animationName: 'spin'\n each(/(?\u003c![\\w$.])(transition|animation)([A-Z]\\w*)?\\s*:\\s*['\"`]/g, (m, i) =>\n report(i, 'css-animation', 'error', `style ${m[1]}${m[2] ?? ''} runs on wall-clock time, not the render clock; drive it from the frame`),\n );\n // CSS text inside a string or template: 'transition: all 300ms', `animation: spin 2s`\n each(/(?\u003c![\\w-])(transition|animation)(-[a-z-]+)?\\s*:\\s*[^;'\"`\\n]*?\\d*\\.?\\d+m?s\\b/g, (m, i) =>\n report(i, 'css-animation', 'error', `CSS ${m[1]} in a string runs on wall-clock time; drive it from the frame`),\n );\n }\n each(/@keyframes\\b/g, (m, i) => report(i, 'css-animation', 'error', '@keyframes do not follow the render clock; animate from the frame'));\n each(/\\bclass(?:Name)?\\s*=\\s*(?:\\{\\s*)?[\"'`]([^\"'`]*)[\"'`]/g, (m, i) => {\n const bad = m[1].split(/\\s+/).find((c) => /^(animate|transition)(-|$)/.test(c));\n if (bad) report(i, 'css-animation', 'error', `utility class ${bad} animates in real time, not per frame`);\n });\n\n // --- nondeterminism\n each(/\\bMath\\.random\\s*\\(/g, (m, i) => report(i, 'nondeterminism', 'error', 'Math.random differs per render and per tab; use rand(seed) or random(seed)'));\n each(/\\b(Date\\.now|performance\\.now)\\s*\\(/g, (m, i) => report(i, 'nondeterminism', 'error', `${m[1]} reads wall-clock time; derive everything from the frame`));\n each(/\\bnew\\s+Date\\s*\\(\\s*\\)/g, (m, i) => report(i, 'nondeterminism', 'error', 'new Date() reads wall-clock time; pass a fixed date'));\n\n // --- will-change-blur\n if (isScript || isCss) {\n const animatedBlur = /blur\\(\\s*\\$\\{|blur\\(\\s*['\"`]?\\s*\\+|blur\\([^)]*\\$\\{/.test(code) || /filter\\s*:\\s*[^'\"`,}\\n]*\\bblur\\b/.test(code);\n if (animatedBlur)\n each(/\\b(willChange|will-change)\\s*:/g, (m, i) => report(i, 'will-change-blur', 'error', 'will-change in a file that animates blur(): compositor promotion leaves stale ghost frames'));\n }\n\n // --- frame-left-top: identifiers derived from useCurrentFrame()\n if (isScript) {\n const tainted = new Set(['frame']);\n for (const m of code.matchAll(/\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*useCurrentFrame\\s*\\(/g)) tainted.add(m[1]);\n propagate(declsOf(code), tainted);\n each(/(?\u003c![\\w$.-])(left|top|right|bottom|marginLeft|marginTop)\\s*:\\s*([^,}\\n]+)/g, (m, i) => {\n const val = m[2];\n if (/^\\s*(number|string|boolean|undefined|null)\\b/.test(val)) return; // type annotation\n const ids = (val.match(/[A-Za-z_$][\\w$]*/g) ?? []).filter((id) => !/^(Math|px|em|calc)$/.test(id));\n const hitId = ids.find((id) => tainted.has(id));\n if (hitId) report(i, 'frame-left-top', 'error', `${m[1]} follows the frame (via ${hitId}): fractional layout offsets snap to whole pixels; move it into transform: translate()`);\n });\n }\n\n // --- raw easing / springs outside lib/anim\n if (!isAnim && (isScript || isCss)) {\n each(/\\bEasing\\.(bezier|spring|elastic|bounce|back|poly|sin|circle|exp|quad|cubic)\\s*\\(/g, (m, i) =>\n report(i, 'raw-easing', 'error', `Easing.${m[1]} outside lib/anim.ts: add a named token to E (one house motion system)`),\n );\n each(/cubic-bezier\\s*\\(/g, (m, i) => report(i, 'raw-easing', 'error', 'raw cubic-bezier: use a named easing token'));\n each(/\\{[^{}]*\\bdamping\\s*:[^{}]*\\bstiffness\\s*:[^{}]*\\}|\\{[^{}]*\\bstiffness\\s*:[^{}]*\\bdamping\\s*:[^{}]*\\}/g, (m, i) =>\n report(i, 'spring-config', 'error', 'inline spring config: add a named preset to SPR in lib/anim.ts and use it'),\n );\n }\n\n // --- interpolate without clamp\n if (isScript) {\n each(/(?\u003c![\\w$])interpolate\\s*\\(/g, (m, i) => {\n const args = parenGroup(code, i + m[0].length - 1);\n const left = /extrapolateLeft\\s*:\\s*['\"]clamp['\"]/.test(args);\n const right = /extrapolateRight\\s*:\\s*['\"]clamp['\"]/.test(args);\n if (left && right) return;\n if (/\\bclamp\\b|CLAMP/i.test(args.replace(/['\"]clamp['\"]/g, ''))) return; // options object from a named constant\n report(i, 'interpolate-clamp', 'error', `interpolate() extends past its range by default (${left ? 'right' : right ? 'left' : 'both sides'} unclamped); use tw()/prog() or clamp both sides`);\n });\n }\n\n // --- colours\n // the :root blocks of a tokens-less (HyperFrames) project declare the palette\n const rootSpans = !tokensPath && (isHtml || isCss) ? [...raw.matchAll(ROOT_BLOCK_RE)].map((m) => [m.index, m.index + m[0].length]) : [];\n const banColor = (i, lit, c) => {\n if (!c || c.a \u003c 0.02) return;\n const lch = lchOf(c);\n const why = bannedRegion(lch);\n if (why)\n report(i, 'banned-color', 'error', `${lit} is ${why} (${fmtLch(lch)}): a hard ban when you invent the look (references/brand-and-color.md §8). The user's own brand colour? Mark it // cinetic:brand-supplied \u003cwhat> and record it in BRIEF.md`);\n };\n each(HEX_RE, (m, i) => {\n const prev = code.slice(Math.max(0, i - 5), i);\n if (/url\\($/.test(prev) || /&$/.test(prev)) return;\n const q = code[i - 1];\n if (isScript && !/['\"`\\s:(,]/.test(q)) return;\n if (isHtml && /href\\s*=\\s*[\"']$|id\\s*=\\s*[\"']$/.test(code.slice(Math.max(0, i - 10), i))) return;\n // \"#2187 built\", \"PR #412\": an all-digit #NNN(N) inside running text is a number label. It counts as a\n // colour only as a whole string ('#123') or after a colour property.\n if (isScript && /^#\\d{3,4}$/.test(m[0])) {\n const alone = /['\"`]/.test(q) && /['\"`]/.test(code[i + m[0].length] ?? '');\n const prop = /(colou?r|background|fill|stroke|border|outline|shadow)[\\w-]*['\"]?\\s*[:=][^;\\n]{0,40}$/i.test(code.slice(Math.max(0, i - 60), i));\n if (!alone && !prop) return;\n }\n banColor(i, m[0], colorOf(m[0])); // every colour, the tokens file and the :root palette included\n if (isTokens || rootSpans.some(([a, b]) => i >= a && i \u003c b)) return;\n if (!paletteName) return;\n if (!tokenHex.has(normHex(m[0])))\n report(i, 'off-token-color', 'error', `${m[0]} is not a brand token (${paletteName}); add it there with its job, or use an existing token`);\n });\n each(/\\b(rgba?|hsla?|oklch|oklab|lab|lch)\\(\\s*([^)]*)\\)/g, (m, i) => {\n if (/\\$\\{/.test(m[2])) return; // built from tokens at runtime\n if (/^(rgba?|oklch)$/.test(m[1])) banColor(i, m[0].slice(0, 40), colorOf(m[0]));\n if (isTokens) return;\n const nums = m[2].split(/[\\s,/]+/).filter(Boolean);\n if (/^rgba?$/.test(m[1]) && nums.length >= 3) {\n const rgb = nums.slice(0, 3).map(Number);\n if (rgb.every((v) => v === 0) || rgb.every((v) => v === 255)) return; // black/white shadows and veils\n }\n report(i, 'off-token-color', 'warn', `colour literal ${m[0].slice(0, 40)}: prefer a token (pure black/white alphas for shadows are fine)`);\n });\n // CSS colour names (orange, gold, tan, purple, …) in a colour property or attribute\n each(/(?\u003c![\\w$-])(colou?r|background(?:-?color)?|fill|stroke|border(?:-?(?:top|bottom|left|right))?(?:-?color)?|outline(?:-?color)?|stop-?color|caret-?color|accent-?color|--[\\w-]+)\\s*[:=]\\s*([^;\\n}]*)/gi, (m, i) => {\n const vals = isScript ? [...m[2].matchAll(/(['\"`])([^'\"`]*)\\1/g)].map((x) => x[2]) : [m[2]];\n for (const v of vals)\n for (const w of v.match(/(?\u003c![\\w.$#-])[a-z]+(?![\\w-])/gi) ?? [])\n if (NAMED[w.toLowerCase()]) banColor(i, `'${w}'`, colorOf(w));\n });\n\n // --- serif and italic (hard bans)\n each(/fontStyle\\s*:\\s*['\"`](italic|oblique)|font-style\\s*:\\s*(italic|oblique)/g, (m, i) =>\n report(i, 'italic', 'error', 'italic/oblique is a hard ban: emphasis comes from size, weight or motion'),\n );\n each(/\u003c(i|em)(\\s[^>]*)?>/g, (m, i) => report(i, 'italic', 'error', `\u003c${m[1]}> renders italic (a hard ban); use a weight or size change`));\n // skew fakes an italic on text: an error on a style or rule that also sets type, a warning elsewhere\n each(/\\bskew[XY]?\\s*(\\(|:)/g, (m, i) => {\n let a = i;\n for (let depth = 0; a > 0; a--) {\n if (code[a] === '}') depth++;\n else if (code[a] === '{' && depth-- === 0) break;\n }\n let b = i;\n for (let depth = 0; b \u003c code.length; b++) {\n if (code[b] === '{') depth++;\n else if (code[b] === '}' && depth-- === 0) break;\n }\n const block = code.slice(a, b);\n if (/\\bfont(Size|Family|Weight)\\b|\\bletterSpacing\\b|\\btypeStyle\\s*\\(|\\bTYPE\\.|font-(size|family|weight)|letter-spacing/.test(block))\n report(i, 'italic', 'error', 'skew on type is a faux italic (a hard ban); move it to a shape, or drop it');\n else report(i, 'italic', 'warn', 'skew: fine on a shape (a smear on a whip); on an element that holds text it is a faux italic, a hard ban');\n });\n const serifMsg = (what) => `${what} is a serif face: a hard ban when you invent the look (one clean sans, references/brand-and-color.md §2). The user's own brand face? Mark it // cinetic:brand-supplied \u003cwhat> and record it in BRIEF.md`;\n each(/@fontsource(?:-variable)?\\/([\\w-]+)|@remotion\\/google-fonts\\/([\\w-]+)/g, (m, i) => {\n if (isSerifFamily(m[1] ?? m[2])) report(i, 'serif', 'error', serifMsg(m[0]));\n });\n {\n // family lists and font shorthands in strings: FONT, FACES, fontFamily, ctx.font, document.fonts.load\n each(/(['\"`])((?:(?!\\1)[^\\n\\\\])*)\\1/g, (m, i) => {\n const v = m[2];\n const looksLikeFont =\n /(^|,)\\s*(serif|sans-serif|monospace|system-ui|ui-serif|ui-sans-serif|ui-monospace|cursive)\\s*(,|$)/.test(v) ||\n /^\\s*(?:(?:normal|bold|\\d{3})\\s+)*\\d+(?:\\.\\d+)?(?:px|em|rem|pt)\\s+\\S/.test(v) ||\n /(^|[\"'])[A-Z][\\w ]* Variable([\"']|$)/.test(v) ||\n /(fontFamily|family)\\s*:\\s*$/.test(code.slice(Math.max(0, i - 24), i));\n if (!looksLikeFont) return;\n const hits = serifIn(v);\n if (hits.length) report(i, 'serif', 'error', serifMsg(hits[0]));\n });\n }\n if (!isScript) {\n each(/font-family\\s*:\\s*([^;}\\n]*)|\\bfont\\s*:\\s*([^;}\\n]*)/g, (m, i) => {\n const hits = serifIn(m[1] ?? m[2]);\n if (hits.length) report(i, 'serif', 'error', serifMsg(hits[0]));\n });\n }\n\n // --- glass and glow (hard bans)\n each(/(?\u003c![\\w-])(backdropFilter|WebkitBackdropFilter|(?:-webkit-)?backdrop-filter)\\s*:\\s*(['\"`]?)\\s*(\\w*)/g, (m, i) => {\n if (m[3] !== 'none') report(i, 'glass', 'error', 'backdrop-filter is glassmorphism (a hard ban): use an opaque surface with one soft shadow (references/brand-and-color.md §11)');\n });\n const resolveColor = (t) => {\n const tok = t.match(/^\\$\\{\\s*(?:[A-Za-z_$][\\w$]*\\.)?([A-Za-z_$][\\w$]*)\\s*\\}$/) ?? t.match(/^var\\(\\s*--([\\w-]+)\\s*\\)$/);\n if (tok) return tokenByName.has(tok[1]) ? colorOf(tokenByName.get(tok[1])) : null;\n return colorOf(t);\n };\n const glowLayer = (i, layer) => {\n if (/\\binset\\b/.test(layer)) return;\n const col = layer.match(/#[0-9a-fA-F]{3,8}\\b|(?:rgba?|oklch|hsla?)\\([^)]*\\)|\\$\\{[^}]*\\}(?!\\s*(px|em|rem))|var\\(--[\\w-]+\\)|\\b(?:white|[a-z]+)\\s*$/);\n if (!col) return;\n const c = resolveColor(col[0].trim());\n if (!c || c.a \u003c 0.02) return;\n const lengths = layer.replace(col[0], ' ').match(/-?(\\$\\{[^}]*\\}|\\d*\\.?\\d+)(px|em|rem)?/g) ?? [];\n const blurTok = lengths[2];\n if (!blurTok) return;\n const blur = /\\$\\{/.test(blurTok) ? Infinity : parseFloat(blurTok);\n const dark = c.rgb ? Math.max(...c.rgb) \u003c= 48 : lchOf(c).L \u003c 0.3;\n if (!dark && blur >= 4)\n report(i, 'glow', 'error', `a ${lchOf(c).C > 0.04 ? 'coloured' : 'light'} shadow with ${Number.isFinite(blur) ? blur + ' px of ' : ''}blur is a glow or halo (a hard ban): shadows are black at 12–22% (0 28px 90px rgba(0,0,0,.16)), and on ink use a luminance step plus a 1 px hairline`);\n };\n const splitLayers = (v) => {\n const out = [];\n let depth = 0;\n let start = 0;\n for (let k = 0; k \u003c v.length; k++) {\n if ('({'.includes(v[k])) depth++;\n else if (')}'.includes(v[k])) depth--;\n else if (v[k] === ',' && depth === 0) (out.push(v.slice(start, k)), (start = k + 1));\n }\n out.push(v.slice(start));\n return out;\n };\n each(/(?\u003c![\\w-])(boxShadow|textShadow|box-shadow|text-shadow)\\s*:\\s*/g, (m, i) => {\n const rest = code.slice(i + m[0].length);\n let v;\n if (isScript) {\n const qm = rest.match(/^(['\"`])/);\n if (!qm) return;\n const end = rest.indexOf(qm[1], 1);\n v = rest.slice(1, end \u003c 0 ? undefined : end);\n } else v = rest.split(/[;}\\n]/)[0];\n for (const layer of splitLayers(v)) glowLayer(i, layer);\n });\n each(/drop-shadow\\(/g, (m, i) => glowLayer(i, parenGroup(code, i + m[0].length - 1)));\n\n // --- gradients\n each(/(repeating-)?(linear|radial|conic)-gradient\\s*\\(/g, (m, i) => {\n const body = parenGroup(code, i + m[0].length - 1);\n const colors = [];\n let dynamic = false;\n const withTokens = body.replace(/\\$\\{\\s*(?:[A-Za-z_$][\\w$]*\\.)?([A-Za-z_$][\\w$]*)\\s*\\}/g, (_, name) => {\n if (tokenByName.has(name)) return tokenByName.get(name);\n dynamic = true;\n return '';\n });\n for (const c of withTokens.matchAll(/#[0-9a-fA-F]{3,8}\\b|rgba?\\(([^)]*)\\)|\\btransparent\\b|var\\(--([\\w-]+)\\)/g)) {\n if (c[0] === 'transparent') colors.push({ rgb: null, a: 0 });\n else if (c[2]) {\n const hex = tokenByName.get(c[2]);\n if (hex) colors.push({ rgb: rgbOf(hex), a: 1 });\n else dynamic = true;\n } else {\n const col = colorOf(c[0]);\n if (col) colors.push(col);\n }\n }\n if (colors.length \u003c 2) return;\n const chromatic = colors.filter((c) => c.rgb && c.a > 0.05).map((c) => oklch(c.rgb)).filter((c) => c.C > 0.04);\n if (chromatic.length >= 2) {\n const hs = chromatic.map((c) => c.h).sort((a, b) => a - b);\n let maxGap = 360 - hs[hs.length - 1] + hs[0];\n for (let k = 1; k \u003c hs.length; k++) maxGap = Math.max(maxGap, hs[k] - hs[k - 1]);\n const spread = 360 - maxGap;\n if (spread > 40) report(i, 'gradient-multihue', 'error', `gradient spans ${Math.round(spread)} degrees of hue: a multi-hue gradient is a hard ban; use one hue (or a tonal field in the palette)`);\n }\n // a coloured radial falloff to nothing is a glow\n if (m[2] === 'radial' && chromatic.length && colors.some((c) => c.a \u003c= 0.02) && colors.some((c) => c.rgb && c.a >= 0.08 && oklch(c.rgb).C > 0.04))\n report(i, 'glow', 'error', 'a coloured radial falloff to transparent is a glow (a hard ban): light a stage with a neutral tonal field, baked as a dithered PNG (references/brand-and-color.md §12)');\n const opaque = colors.filter((c) => c.rgb && c.a >= 0.99);\n if (!dynamic && opaque.length === colors.length && opaque.length >= 2) {\n const ls = opaque.map((c) => luma(c.rgb));\n const span = Math.max(...ls) - Math.min(...ls);\n if (span > 0 && span \u003c 30)\n report(i, 'gradient-low-span', 'warn', `gradient spans only ${span.toFixed(0)} code values: 8-bit CSS gradients band; use a dithered PNG (scripts/dither-gradient.py)`);\n }\n });\n\n // --- emoji, sparkle glyphs, stock icon / confetti / particle libraries (hard bans)\n each(/\\p{Extended_Pictographic}️|\\p{Emoji_Presentation}|[✦✧]/gu, (m, i) =>\n report(i, 'emoji', 'error', `${m[0]} is an emoji or sparkle glyph (a hard ban): every image comes from the product's own objects`),\n );\n each(/(?:\\bfrom\\s+|\\bimport\\s+|\\brequire\\s*\\(\\s*|\\bimport\\s*\\(\\s*)(['\"])([^'\"]+)\\1|\u003cscript[^>]*\\bsrc\\s*=\\s*(['\"])([^'\"]+)\\3/g, (m, i) => {\n const mod = m[2] ?? m[4];\n if (ICON_LIBS.test(mod) || (m[4] && /confetti|particles|lucide|font-?awesome|feather|ionicons|heroicons|tabler-icons|phosphor/i.test(mod)))\n report(i, 'stock-decoration', 'error', `${mod}: stock icons, sparkles, confetti and particles are a hard ban; draw what the product itself shows`);\n });\n\n // --- premount (4.0.529: premounting only runs outside renders, and never with layout=\"none\")\n each(/premountFor\\s*=/g, (m, i) => {\n const tagStart = code.lastIndexOf('\u003c', i);\n const tagEnd = code.indexOf('>', i);\n const tag = code.slice(tagStart, tagEnd \u003c 0 ? undefined : tagEnd);\n if (/layout\\s*=\\s*(\\{\\s*)?['\"]none['\"]/.test(tag)) report(i, 'premount', 'warn', 'premountFor is silently ignored with layout=\"none\" (and in every render); remove it');\n else report(i, 'premount', 'warn', 'premountFor does nothing in a render (it only hides preview loading); remove it unless the Studio needs it');\n });\n\n // --- in-browser motion blur, remote fonts\n each(/\\b(CameraMotionBlur|HtmlInCanvasMotionBlur|Trail)\\b/g, (m, i) => {\n if (!/@remotion\\/motion-blur/.test(code)) return;\n report(i, 'inbrowser-blur', 'warn', `${m[1]} accumulates in 8 bits inside Chromium (rings, tints); render the blur with scripts/render.sh --blur`);\n });\n each(/@remotion\\/[\\w-]+-fonts\\b|@import\\s+url\\(\\s*['\"]?https?:|\u003clink[^>]+href\\s*=\\s*['\"]https?:[^'\"]*(css|font)/g, (m, i) =>\n report(i, 'remote-font', 'warn', 'fonts fetched at render time: ship local variable fonts (@fontsource-variable/*) behind FontGate'),\n );\n\n // --- placeholder brand (markers live in comments, so scan the raw source)\n for (const m of raw.matchAll(/cinetic:placeholder\\b/g))\n report(\n m.index,\n 'placeholder',\n 'error',\n \"the starter's stand-in brand is still here: choose this film's palette, typeface, mark and name (or apply the user's brand) per references/brand-and-color.md, then delete this marker\",\n );\n\n // --- text flow: copy-literal, text-spring, text-pace, typing-rate, per-letter\n if (isScript) {\n const decls = declsOf(code);\n const isAct = /(^|\\/)acts\\//.test(norm) || /^(act|scene|shot)(\\d|[-_.])/i.test(basename(file));\n const house = /(^|\\/)(fx|lib)\\//.test(norm); // the house components animate per character on purpose\n const lit = (t) => `\"${t.trim().replace(/\\s+/g, ' ').slice(0, 40)}\"`;\n const copyMsg = (t) => `${lit(t)} is on-screen text written in an act: put it in COPY (timeline.ts line() or chain()) and read it with copy() or chainOf(), so grid-check counts its words, times its hold and catches a sentence arriving at once`;\n\n if (isAct && /\\.(t|j)sx$/.test(file)) {\n each(/(?\u003c![=\\-])>([^\u003c>{}();=|&:]*)\u003c/g, (m, i) => {\n if (twoWords(m[1])) report(i + 1, 'copy-literal', 'warn', copyMsg(m[1]));\n });\n each(/>\\s*\\{\\s*(['\"`])((?:(?!\\1)[^\\n$])*)\\1\\s*\\}/g, (m, i) => {\n if (twoWords(m[2])) report(i + 1, 'copy-literal', 'warn', copyMsg(m[2]));\n });\n each(/(?\u003c![\\w-])(words|lines|fragments)\\s*=\\s*\\{\\s*\\[([^\\]]*)\\]/g, (m, i) => {\n const strings = [...m[2].matchAll(/(['\"`])((?:(?!\\1)[^\\n$])*)\\1/g)].map((x) => x[2]).join(' ');\n if (twoWords(strings)) report(i, 'copy-literal', 'warn', copyMsg(strings));\n });\n each(/(?\u003c![\\w-])(text|label|title|caption|line|headline|subtitle|children)\\s*=\\s*(?:\\{\\s*)?(['\"`])((?:(?!\\2)[^\\n$])*)\\2/g, (m, i) => {\n if (twoWords(m[3])) report(i, 'copy-literal', 'warn', copyMsg(m[3]));\n });\n // a sentence written one word per element: single-word JSX texts in sibling (or cousin)\n // elements, with only balanced closing and opening tags between them\n const single = [...code.matchAll(/(?\u003c![=\\-])>(\\s*)([A-Za-z][A-Za-z'’-]*[.,;:!?]?)\\s*\u003c\\//g)].map((m) => ({ at: m.index + 1 + m[1].length, word: m[2], end: m.index + m[0].length - 2 }));\n const nextWord = (from) => {\n // skip \"\u003c/a>\" and \"\u003cb ...>\" pairs (balanced) and whitespace; return the index of the next JSX text\n let j = from;\n let depth = 0;\n while (j \u003c code.length) {\n const ws = /^\\s*/.exec(code.slice(j, j + 400))[0].length;\n j += ws;\n if (code.startsWith('\u003c/', j)) {\n const e = code.indexOf('>', j);\n if (e \u003c 0) return -1;\n depth--;\n j = e + 1;\n } else if (code[j] === '\u003c' && /[A-Za-z]/.test(code[j + 1] ?? '')) {\n const t = tagAt(code, j);\n if (!t) return -1;\n if (!t.text.endsWith('/>')) depth++;\n j += t.text.length;\n } else return depth === 0 ? j : -1;\n }\n return -1;\n };\n for (let k = 0; k \u003c single.length; k++) {\n const run = [single[k]];\n while (true) {\n const last = run[run.length - 1];\n const n = single.find((x) => x.at > last.end);\n if (!n || nextWord(last.end) !== n.at) break;\n run.push(n);\n }\n if (run.length >= 2) {\n const text = run.map((x) => x.word).join(' ');\n if (twoWords(text)) report(run[0].at, 'copy-literal', 'warn', copyMsg(text) + ' (written one word per element)');\n k += run.length - 1;\n }\n }\n }\n\n // text-spring: values from an overshooting spring, followed through the declarations into a text element\n const aliases = new Set();\n for (const [lhs, rhs] of decls) {\n const a = rhs.trim().match(/^SPR\\.(\\w+)$/);\n if (a && OVERSHOOT.has(a[1]) && /^[A-Za-z_$][\\w$]*$/.test(lhs)) aliases.add(lhs);\n }\n const springy = (expr) => {\n for (const m of expr.matchAll(/\\b(spr|spring)\\s*\\(/g)) {\n const args = parenGroup(expr, m.index + m[0].length - 1);\n const preset = args.match(/SPR\\.(\\w+)/);\n if (preset) {\n if (OVERSHOOT.has(preset[1])) return true;\n continue;\n }\n if ([...aliases].some((a) => new RegExp(`\\\\b${a.replace('

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. -…

, '\\\\

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. -…

)}\\\\b`).test(args))) return true;\n if (m[1] === 'spr' && args.split(',').length \u003c= 2) return true; // spr()'s default is SPR.snap\n if (m[1] === 'spring' && !/\\bconfig\\b/.test(args)) return true; // Remotion's default spring overshoots\n }\n return false;\n };\n const sprung = propagate(decls, new Set(decls.filter(([, rhs]) => springy(rhs)).flatMap(([lhs]) => lhs.match(/[A-Za-z_$][\\w$]*/g) ?? [])));\n // an overshooting preset handed to a component as its spring: cfg={SPR.word}, config={LAND}\n const springProp = (tagText) =>\n [...tagText.matchAll(/\\b(?:cfg|config|spring|spr)\\s*=\\s*\\{\\s*(?:SPR\\.(\\w+)|([A-Za-z_$][\\w$]*))\\s*\\}/g)].some((p) => (p[1] ? OVERSHOOT.has(p[1]) : aliases.has(p[2])));\n // the children of the element whose opening tag starts at i: do they put text on screen?\n const textChildren = (tag, i) => {\n if (tag.text.endsWith('/>')) return false;\n const close = code.indexOf(`\u003c/${tag.name}>`, i + tag.text.length);\n if (close \u003c 0) return false;\n const inner = code.slice(i + tag.text.length, close);\n if (/\\bcopy\\s*\\(|\\{\\s*(['\"`])[^'\"`]*[A-Za-z]/.test(inner)) return true;\n // JSX text: what is left once expressions and tags are removed\n let bare = '';\n let depth = 0;\n for (const ch of inner) {\n if (ch === '{') depth++;\n else if (ch === '}') depth--;\n else if (!depth) bare += ch;\n }\n return /[A-Za-z]{2,}/.test(bare.replace(/\u003c[^>]*>/g, ' '));\n };\n each(/\u003c[A-Za-z][\\w.]*/g, (m, i) => {\n const tag = tagAt(code, i);\n if (!tag) return;\n const isText = /\\bdata-text\\b/.test(tag.text) || TEXT_TAGS.test(tag.name);\n // only the names the tag's props read (not its prop names or strings)\n const ids = isText ? namesIn(tag.text.replace(/^\u003c[\\w.]+/, '').replace(/\\b[\\w-]+\\s*=(?=\\s*[{'\"])/g, ' ')).filter((id) => sprung.has(id)) : [];\n if ((isText && (ids.length || springy(tag.text) || springProp(tag.text))) || (!isText && springProp(tag.text) && textChildren(tag, i)))\n report(i, 'text-spring', 'warn', `an overshooting spring moves text here${ids.length ? ` (via ${ids[0]})` : ''}: a word that bounces into place reads as a gimmick and costs the read; ease it in (Words, Chain) or use SPR.calm`);\n });\n\n // text-pace: the crisp tempo written into a calm film\n if (filmPace === 'calm') {\n each(/\u003c(Words|MaskRise)\\b/g, (m, i) => {\n const tag = tagAt(code, i);\n if (!tag || /pace\\s*=\\s*\\{\\s*PACE\\.crisp\\b/.test(tag.text)) return;\n const num = (prop) => {\n const v = tag.text.match(new RegExp(`\\\\b${prop}\\\\s*=\\\\s*\\\\{\\\\s*(?:f60\\\\(\\\\s*)?([\\\\d.]+)`));\n return v ? Number(v[1]) : null;\n };\n const hurried = [['stagger', 8, 'words land 10-12 f apart'], ['dur', 24, 'a word rises over 34-40 f'], ['outDur', 18, 'a fragment leaves over 22-24 f']]\n .map(([prop, min, why]) => [prop, num(prop), min, why])\n .filter(([, v, min]) => v !== null && v \u003c min);\n for (const [prop, v, min, why] of hurried)\n report(i, 'text-pace', 'warn', `\u003c${m[1]} ${prop}={${v}}> is the crisp tempo (\u003c ${min} f) in a calm film: ${why}; drop the prop to take the pace's default, or pass pace={PACE.crisp} for a deliberate crisp passage`);\n });\n }\n\n // typing-rate: \u003cTypeOn cps> and hand-rolled type-ons\n each(/\u003cTypeOn\\b/g, (m, i) => {\n const tag = tagAt(code, i);\n const cps = tag?.text.match(/\\bcps\\s*=\\s*\\{\\s*([\\d.]+)/);\n if (cps && Number(cps[1]) > 30) report(i, 'typing-rate', 'warn', `\u003cTypeOn cps={${cps[1]}}> types faster than a viewer reads along (30 chars/s at most; 18-24 for text that must be read live)`);\n });\n if (!house) {\n const frameish = new Set(['frame']);\n for (const mm of code.matchAll(/\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*useCurrentFrame\\s*\\(/g)) frameish.add(mm[1]);\n propagate(decls, frameish);\n // the declaration of a name that is in force at `at`: the nearest one before it (a loop's\n // `let i = 0` is not the `i` another component derives from the frame)\n const declOf = (id, at) => {\n const list = decls.filter(([lhs]) => lhs === id);\n return list.filter(([, , k]) => k \u003c at).pop() ?? list[0];\n };\n // a number a name stands for (const CPS = 3), so `* CPS` counts like `* 3`\n const numOf = (tok, at) => {\n if (/^[\\d.]+$/.test(tok)) return Number(tok);\n const d = declOf(tok, at);\n return d && /^\\s*[\\d.]+\\s*$/.test(d[1]) ? Number(d[1]) : null;\n };\n const perFrame = (expr, at, depth = 0) => {\n // characters added per frame by a count expression; null when it does not grow with the frame\n if (depth > 4) return null;\n const id = expr.trim().match(/^[A-Za-z_$][\\w$]*$/)?.[0];\n if (id) {\n const d = declOf(id, at);\n return d ? perFrame(d[1], d[2], depth + 1) : frameish.has(id) ? 1 : null;\n }\n const names = namesIn(expr);\n if (!names.some((x) => frameish.has(x))) return null;\n // the count is built from other names (Math.max(0, left), shown + 1): follow each that is\n // declared from the frame, and take the fastest\n const via = names.filter((x) => frameish.has(x) && x !== 'frame' && declOf(x, at)).map((x) => perFrame(x, at, depth + 1)).filter((r) => r !== null);\n const mulTok = expr.match(/\\*\\s*([\\d.]+|[A-Za-z_$][\\w$]*)/)?.[1];\n const divTok = expr.match(/\\/\\s*([\\d.]+|[A-Za-z_$][\\w$]*)/)?.[1];\n const mul = mulTok !== undefined ? numOf(mulTok, at) : null;\n const div = divTok !== undefined ? numOf(divTok, at) : null;\n const own = mul ? mul : div ? 1 / div : 1;\n return via.length && !mul && !div ? Math.max(...via) : own;\n };\n each(/\\.(slice|substring|substr)\\s*\\(\\s*0\\s*,/g, (m, i) => {\n const args = parenGroup(code, i + m[0].indexOf('('));\n const rate = perFrame(args.slice(args.indexOf(',') + 1), i);\n if (rate !== null && rate * filmFps > 30)\n report(i, 'typing-rate', 'warn', `a hand-rolled type-on at about ${Math.round(rate * filmFps)} chars/s: over 30 the text is a blur, not a read; use \u003cTypeOn> (calm: 20 chars/s) and hold the finished string >= 72 f`);\n });\n }\n\n // per-letter: a string split into letters and mapped into animated spans\n if (!house) {\n // is x (the thing spread or Array.from'd) a string? An array of rows spread is not letters.\n const STRINGY_NAME = /^(text|word|words?Text|label|title|caption|headline|str|string|name|letters)$/i;\n const stringy = (x) => {\n const t = x.replace(/\\(\\)$/, '');\n if (/\\.(text|label|title|caption|headline|name)$/.test(t)) return true;\n const id = t.match(/^[A-Za-z_$][\\w$]*$/)?.[0];\n if (!id) return false;\n if (STRINGY_NAME.test(id)) return true;\n if (new RegExp(`\\\\b${id.replace(/\\$/g, '\\\\

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. -…

)}\\\\??\\\\s*:\\\\s*string\\\\b`).test(code)) return true; // typed string\n const d = decls.find(([lhs]) => lhs === id);\n return !!d && /^\\s*(['\"`]|copy\\s*\\(|String\\s*\\(|[\\w$.]+\\.text\\b)/.test(d[1]);\n };\n each(/(\\.split\\(\\s*(['\"])\\2\\s*\\)|\\[\\s*\\.\\.\\.\\s*([\\w.$]+(?:\\(\\))?)\\s*\\]|Array\\.from\\(\\s*([\\w.$]+)\\s*\\))\\s*\\.map\\s*\\(/g, (m, i) => {\n const x = m[3] ?? m[4];\n if (x && !stringy(x)) return;\n const body = parenGroup(code, i + m[0].length - 1);\n if (/style\\s*=|transform|opacity/.test(body))\n report(i, 'per-letter', 'warn', 'letters animated one by one: animate words or whole fragments (a per-letter rise slows the read and reads as a trick); the one hero word may keep it with // lint-ok: hero word');\n });\n }\n }\n\n // --- HyperFrames\n if (isHtml || (isScript && /gsap|timeline\\(/.test(code))) {\n each(/\\.from\\s*\\(/g, (m, i) => {\n if (/(Array|Buffer|Object|Set|Map)\\.from/.test(code.slice(Math.max(0, i - 7), i + 5))) return;\n report(i, 'hf-from', 'error', 'gsap .from() captures the current DOM state and breaks on seek; use .fromTo()');\n });\n each(/repeat\\s*:\\s*-1\\b/g, (m, i) => report(i, 'hf-repeat', 'error', 'repeat: -1 has no end: seek-driven renders need finite repeats'));\n }\n if (isHtml) {\n each(/\u003cbr\\s*\\/?>/gi, (m, i) => report(i, 'hf-br', 'error', '\u003cbr> breaks the line layout rules; use separate line elements'));\n each(/\u003caudio\\b[^>]*>/gi, (m, i) => {\n if (!/\\bid\\s*=/.test(m[0])) report(i, 'hf-audio-id', 'error', '\u003caudio> needs an id');\n });\n }\n}\n\n// ---------------------------------------------------------------------------------- report\nconst errors = issues.filter((x) => x.severity === 'error').length;\nconst warnings = issues.length - errors;\nconst failed = errors > 0 || (opt.strict && warnings > 0);\nif (opt.json) {\n console.log(\n JSON.stringify(\n { tool: 'lint-film', root: relative(process.cwd(), root) || '.', tokens: paletteName, files: files.length, errors, warnings, suppressed, brandSupplied, pass: !failed, issues },\n null,\n 1,\n ),\n );\n} else {\n for (const x of issues) console.log(`${x.file}:${x.line}:${x.col} ${x.severity === 'error' ? 'error' : 'warn '} ${x.rule} ${x.message}\\n ${x.excerpt}`);\n if (!paletteName) console.log('note: no tokens file or :root palette found; off-token colours were not checked (pass --tokens)');\n console.log(\n `lint-film: ${files.length} files, ${errors} errors, ${warnings} warnings${suppressed ? `, ${suppressed} suppressed` : ''}${brandSupplied ? `, ${brandSupplied} brand-supplied (list each in BRIEF.md)` : ''} -> ${failed ? 'FAIL' : 'pass'}`,\n );\n}\nprocess.exit(failed ? 1 : 0);\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":59588,"content_sha256":"8e92ae8b970f44b4af7ce00ea088438532bdf0352a317319d3f9675cb07091c0"},{"filename":"scripts/measure-speed.py","content":"#!/usr/bin/env python3\n\"\"\"Decide how many motion-blur samples each frame needs, from how fast things move on screen.\n\nOptical flow on a sharp render gives, per frame, the speed of the fastest moving content. Each\noutput frame is later rendered as n sub-frames across the shutter (the FilmSub composition) so that\nneighbouring samples land at most --step px apart: fast moves smear instead of stamping copies,\nand still frames render once. The output (out/samples.json) is passed to FilmSub as --props.\n\nOnly motion the flow can vouch for counts, because every over-read costs render time for nothing\n(a raw maximum read 2-3x too fast on flat shapes, flashes and repeating grids):\n dense = the k-th fastest *consistent* moving pixel of Farneback flow: forward and backward flow\n agree there (|fwd + bwd| \u003c 0.5 px + 10%), k = max(--dense-min-px, (1-pct)*consistent);\n the backward flow is computed only when the raw dense read could win\n sparse = the --track-pct percentile of *consistent* corner tracks (pyramidal LK, forward-backward\n error \u003c 1 px, and at least --track-support neighbours moving the same way), not the maximum\n v = max(dense, sparse) scaled to the source width. A one-frame spike (> --spike x both\n neighbours) stands only when dense and sparse agree within 30%; otherwise it takes the\n larger neighbour, because real motion is smooth at 60 fps\n speed[f] = max(v[f], v[f+1]) (the shutter straddles the frame, so it sees both neighbours' moves)\nThen a +-N frame temporal max filter (no n flicker 4 -> 34 -> 4, which reads as strobing blur),\noptional floors over known fast ranges and an optional analytic speed track exported from the\ntimeline (max of both wins).\n\nCuts. A pair of frames where most pixels change (--cut-frac) and the flow cannot explain the change\n(a hard cut, a flash, a full-frame luminance flip) contributes no speed, is listed in `cuts` and\nrenders with one sample, so its frame never averages the two shots into a double exposure (at an act\nboundary src/blur.ts also clamps the samples to the act). A whip pan also changes most pixels, but the flow explains it, so it keeps\nits speed. Smaller unexplained changes are listed as suspect_cuts; with --cues, the ones away from\nact boundaries are flagged.\n\n n = 1 if speed \u003c --still\n n = clamp(ceil(shutter/360 * speed / step), --min, --max) otherwise\n --budget X then caps the total at X times the frame count: the fastest frames are trimmed first\n (never below --min), and the capped frames are listed in the log and the JSON.\nA frame whose samples would sit more than --step px apart (over the cap, or capped by --budget) gets\na shorter shutter in `shutters` (src/blur.ts reads it), so it draws one clean, shorter streak instead\nof stepped copies; --no-short-shutter keeps the full shutter. Frames faster than --too-fast (80 px/f)\nare listed as too_fast: blur cannot make them read, so the log asks for a redesign of the move.\n\n--shutter must match SHUTTER in src/blur.ts (240 by default).\n\nPace. Every run also reports how fast the film moves (the `pace` block, from the per-pair flow\nspeed before the shutter and max filters): the median and 90th percentile speed of moving frames,\nthe share of frames over 12, 25 and 40 px/f, the longest run under 2 px/f, and how many moves pass\n40 px/f. --pace calm warns when the 90th percentile passes 25 px/f or more than 6% of frames pass\n40 px/f (crisp: 45 px/f, 15%): calm films keep ordinary moves under 25 px/f and save speed for one\nor two moments. --copy out/cues.json (or a --cues file that has a copy block) adds the speed inside\neach copy entry's reading windows (its `read`: the timeline's READ, so a chain's picture beat is\nnot a read; [resolved, out) in an older cues.json): the frame should be near still while text is\nread, so a median over 3 px/f or a peak over 25 px/f (crisp 40) warns. The pace defaults to the\ncues file's `pace`. Run it on a sharp render (a preview, or the speed pass of render.sh --blur):\nthe numbers are about the design, not the blur.\n\nBlind spots (use --floor or --analytic there, and check those frames at 1:1 in the master): an\nedge with no 2D structure (a flat band sweeping the full frame reads ~0), a small flat object\njumping more than ~150 px/f at the default analysis width (both trackers lose it), and the first\nframe of an object entering from off-frame (the max filter usually covers it).\n\nUsage:\n python3 scripts/measure-speed.py sharp.mp4 out/samples.json\n python3 scripts/measure-speed.py sharp.mp4 out/samples.json --shutter 240 --step 3 --min 4 --max 32 --still 2\n python3 scripts/measure-speed.py sharp.mp4 out/samples.json --floor 1210-1260:16 --analytic out/speed.json\n python3 scripts/measure-speed.py sharp.mp4 out/samples.json --budget 6 # at most 6x the frame count\n python3 scripts/measure-speed.py excerpt.mp4 out/samples.json --offset 600 # excerpt starts at film frame 600\n python3 scripts/measure-speed.py out/preview.mp4 out/qa/speed.json --pace calm --copy out/cues.json\n\n--analytic FILE: JSON {\"speed\": [px/f per film frame]} or {\"ranges\": [{\"from\": a, \"to\": b, \"speed\": v}]}\n (px per frame at output resolution, absolute film frames).\n--offset F: the video starts at film frame F; groups/speed are padded with F still frames so\n FilmSub indexes them correctly (render.sh --frames uses this).\nWrites JSON {groups, speed, offset, frames, fps, width, height, shutter, step, min, max, still,\ncuts, suspect_cuts, subframes, multiple, peak, budget, capped, too_fast, short_shutter, shutters?,\nfastest_frames, pace, pace_warnings, copy_speed?, ...}. Exit 0 on success (pace warnings included), 1 on\nfailure.\n\"\"\"\nimport argparse\nimport json\nimport math\nimport os\nimport subprocess\nimport sys\nimport time\nfrom fractions import Fraction\nfrom multiprocessing import Pool\n\nimport numpy as np\n\n\ndef parse_args():\n ap = argparse.ArgumentParser(description=__doc__.split('\\n\\n')[0], formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=__doc__.split('\\n\\n', 1)[1])\n ap.add_argument('video', help='sharp (unblurred) render of the film or an excerpt, or an earlier samples JSON to re-decide groups from its speed track')\n ap.add_argument('out', help='samples JSON to write (e.g. out/samples.json)')\n ap.add_argument('--shutter', type=float, default=240, help='shutter angle in degrees; must match src/blur.ts')\n ap.add_argument('--step', type=float, default=3.0, help='max px between neighbouring samples at output size')\n ap.add_argument('--min', type=int, default=4, help='min samples on a moving frame')\n ap.add_argument('--max', type=int, default=32, help='max samples per frame (32 covers 144 px/f at a 3 px step)')\n ap.add_argument('--still', type=float, default=2.0, help='px/f below which a frame renders once')\n ap.add_argument('--width', type=int, default=960, help='analysis width (height keeps the aspect)')\n ap.add_argument('--mask-thr', type=int, default=6, help='grey-level change that counts as moving')\n ap.add_argument('--dense-pct', type=float, default=99.9, help='dense-flow robust-max percentile')\n ap.add_argument('--dense-min-px', type=int, default=30, help='min pixel count behind the dense robust max')\n ap.add_argument('--track-pct', type=float, default=90, help='percentile of the consistent corner tracks')\n ap.add_argument('--track-support', type=int, default=2, help='neighbouring tracks that must move the same way')\n ap.add_argument('--fb-tol', type=float, default=0.5, help='forward-backward flow error allowed (px at analysis size, + 10%%)')\n ap.add_argument('--spike', type=float, default=1.25, help='a pair this much faster than both neighbours needs both trackers to agree (0 = off)')\n ap.add_argument('--maxfilter', type=int, default=2, help='temporal max filter radius in frames (0 = off)')\n ap.add_argument('--cut-mad', type=float, default=12.0, help='mean grey change above which a pair may be a hard cut')\n ap.add_argument('--cut-frac', type=float, default=0.5, help='share of changed pixels above which an unexplained change is a cut')\n ap.add_argument('--budget', type=float, default=0, help='cap total sub-frames at this multiple of the frame count (0 = off)')\n ap.add_argument('--too-fast', type=float, default=80, help='px/f above which a move is flagged for redesign')\n ap.add_argument('--min-shutter', type=float, default=45, help='shortest fallback shutter in degrees')\n ap.add_argument('--no-short-shutter', action='store_true', help='keep the full shutter on frames the samples cannot cover')\n ap.add_argument('--floor', action='append', default=[], help=\"a-b:n, min samples over frames a..b (repeatable, comma list ok)\")\n ap.add_argument('--analytic', help='JSON speed track exported from the timeline (px/f)')\n ap.add_argument('--cues', help='out/cues.json: warn about hard cuts that are not act boundaries')\n ap.add_argument('--pace', choices=['calm', 'crisp'], help=\"speed limits for the pace report (default: the cues file's pace)\")\n ap.add_argument('--copy', help=\"cues.json with a copy block: the speed inside each text's reading window\")\n ap.add_argument('--offset', type=int, default=0, help='film frame index of the first frame of the video')\n ap.add_argument('--workers', type=int, default=max(1, min(4, (os.cpu_count() or 2) // 2)))\n ap.add_argument('--quiet', action='store_true')\n return ap.parse_args()\n\n\ndef probe(path):\n r = subprocess.run(['ffprobe', '-v', 'error', '-select_streams', 'v:0', '-show_entries',\n 'stream=width,height,avg_frame_rate,r_frame_rate', '-of', 'json', path], capture_output=True, text=True)\n if r.returncode:\n sys.exit(f'measure-speed.py: cannot read {path}: {r.stderr.strip()}')\n s = json.loads(r.stdout)['streams'][0]\n fps = None\n for k in ('avg_frame_rate', 'r_frame_rate'):\n try:\n f = Fraction(s[k])\n if f > 0:\n fps = float(f)\n break\n except (ValueError, ZeroDivisionError):\n pass\n return s['width'], s['height'], fps\n\n\nCFG = {}\n\n\ndef init_worker(cfg):\n import cv2\n cv2.setNumThreads(1)\n CFG.update(cfg)\n\n\ndef pair_speed(job):\n \"\"\"Speed (px/f at analysis size) of the move a -> b, plus cut evidence.\n\n Returns (dense, sparse, mad, resid, frac): the consistent dense speed, the consistent track\n speed, the mean grey change, how much of it the flow leaves unexplained, and the changed share.\n \"\"\"\n import cv2\n a, b = job\n diff = cv2.absdiff(a, b)\n moving = diff > CFG['mask_thr']\n count = int(moving.sum())\n mad = float(diff.mean())\n frac = count / moving.size\n if count \u003c 20:\n return 0.0, 0.0, mad, 0.0, frac\n h, w = a.shape\n fwd = cv2.calcOpticalFlowFarneback(a, b, None, 0.5, 5, 21, 3, 7, 1.5, 0)\n gx, gy = np.meshgrid(np.arange(w, dtype=np.float32), np.arange(h, dtype=np.float32))\n mx, my = gx + fwd[..., 0], gy + fwd[..., 1]\n mag = np.hypot(fwd[..., 0], fwd[..., 1])\n\n def kth(values):\n n = len(values)\n if n \u003c CFG['dense_min_px']:\n return 0.0\n k = min(n, max(CFG['dense_min_px'], int(math.ceil(n * (1 - CFG['dense_pct'] / 100.0)))))\n return float(np.partition(values, n - k)[n - k])\n\n # how much of the change the flow explains: warp b back onto a (for cut detection)\n resid = 1.0\n if mad > CFG['cut_mad'] or frac > CFG['cut_frac']:\n warped = cv2.remap(b, mx, my, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)\n resid = float(cv2.absdiff(a, warped).mean()) / max(mad, 1e-6)\n sparse = 0.0\n pts = cv2.goodFeaturesToTrack(a, 600, 0.01, 5, mask=cv2.dilate(moving.astype(np.uint8), None, iterations=4))\n if pts is not None and len(pts) >= 3:\n lk = dict(winSize=(21, 21), maxLevel=4, criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01))\n nxt, st1, _ = cv2.calcOpticalFlowPyrLK(a, b, pts, None, **lk)\n back, st2, _ = cv2.calcOpticalFlowPyrLK(b, a, nxt, None, **lk)\n good = (st1[:, 0] == 1) & (st2[:, 0] == 1) & (np.linalg.norm((back - pts)[:, 0], axis=1) \u003c 1.0)\n p0, vel = pts[good, 0], (nxt - pts)[good, 0]\n if len(p0) >= 3:\n # a track counts only when neighbours (within 64 px) move the same way: one object, not\n # a lone tracker that jumped to a look-alike corner\n sp = np.linalg.norm(vel, axis=1)\n near = np.linalg.norm(p0[:, None] - p0[None], axis=2) \u003c 64\n same = np.linalg.norm(vel[:, None] - vel[None], axis=2) \u003c np.maximum(1.0, 0.15 * sp[:, None])\n support = (near & same).sum(axis=1) - 1\n cons = sp[support >= CFG['track_support']]\n if len(cons) >= 3:\n sparse = float(np.percentile(cons, CFG['track_pct']))\n\n raw = kth(mag[moving])\n if raw \u003c= max(sparse, 1.0):\n # the dense read cannot win (or the frame is still): skip the backward flow, half the cost\n return raw, sparse, mad, resid, frac\n # forward-backward check: where the flow is real, following it forward and then back returns\n # to the start. Flat interiors, occluded edges and aliased repeats fail it.\n bwd = cv2.calcOpticalFlowFarneback(b, a, None, 0.5, 5, 21, 3, 7, 1.5, 0)\n ex = fwd[..., 0] + cv2.remap(bwd[..., 0], mx, my, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)\n ey = fwd[..., 1] + cv2.remap(bwd[..., 1], mx, my, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)\n ok = moving & (np.hypot(ex, ey) \u003c CFG['fb_tol'] + 0.1 * mag)\n dense = kth(mag[ok])\n return dense, sparse, mad, resid, frac\n\n\ndef frames(path, W, H):\n dec = subprocess.Popen(['ffmpeg', '-v', 'error', '-i', path, '-vf', f'scale={W}:{H}:flags=area', '-f', 'rawvideo',\n '-pix_fmt', 'gray', '-'], stdout=subprocess.PIPE)\n while True:\n b = dec.stdout.read(W * H)\n if len(b) \u003c W * H:\n break\n yield np.frombuffer(b, np.uint8).reshape(H, W)\n dec.wait()\n\n\ndef parse_floors(specs):\n out = []\n for spec in specs:\n for part in spec.split(','):\n part = part.strip()\n if not part:\n continue\n try:\n rng, n = part.split(':')\n a, b = (rng.split('-') + [rng])[:2]\n out.append((int(a), int(b), int(n)))\n except ValueError:\n sys.exit(f'measure-speed.py: bad --floor {part!r} (want a-b:n, e.g. 1210-1260:16)')\n return out\n\n\ndef load_analytic(path, total):\n d = json.load(open(path))\n sp = np.zeros(total)\n if isinstance(d, dict) and 'speed' in d:\n arr = np.asarray(d['speed'], dtype=float)[:total]\n sp[:len(arr)] = arr\n ranges = d.get('ranges', []) if isinstance(d, dict) else d\n for r in ranges or []:\n a, b = int(r['from']), int(r['to'])\n sp[max(0, a):min(total, b + 1)] = np.maximum(sp[max(0, a):min(total, b + 1)], float(r['speed']))\n return sp\n\n\ndef ranges_of(idx, offset=0):\n \"\"\"[[a, b], ...] runs of consecutive frame indices.\"\"\"\n out, start = [], None\n for k, i in enumerate(idx):\n if start is None:\n start = i\n if k + 1 == len(idx) or idx[k + 1] != i + 1:\n out.append([start + offset, i + offset])\n start = None\n return out\n\n\ndef measure(a, log, t0):\n \"\"\"Optical-flow speed per frame of a video: (speed, flow_speed, cuts, suspects, meta).\"\"\"\n src_w, src_h, fps = probe(a.video)\n W = min(a.width, src_w) // 2 * 2\n H = int(round(src_h * W / src_w)) // 2 * 2\n scale = src_w / W\n cfg = dict(mask_thr=a.mask_thr, dense_pct=a.dense_pct, dense_min_px=a.dense_min_px, track_pct=a.track_pct,\n track_support=a.track_support, fb_tol=a.fb_tol, cut_mad=a.cut_mad, cut_frac=a.cut_frac)\n res = [] # per pair (i-1 -> i)\n batch = []\n pool = Pool(a.workers, initializer=init_worker, initargs=(cfg,)) if a.workers > 1 else None\n if pool is None:\n init_worker(cfg)\n\n def flush():\n if batch:\n res.extend(pool.map(pair_speed, batch) if pool else map(pair_speed, batch))\n batch.clear()\n if len(res) % 240 \u003c 64:\n log(f' measured {len(res) + 1} frames ({time.time() - t0:.0f}s)')\n\n n_frames, prev = 0, None\n for f in frames(a.video, W, H):\n n_frames += 1\n if prev is not None:\n batch.append((prev, f))\n if len(batch) >= 64:\n flush()\n prev = f\n flush()\n if pool:\n pool.close()\n pool.join()\n if n_frames == 0:\n sys.exit(f'measure-speed.py: no frames decoded from {a.video}')\n\n dense = np.array([0.0] + [r[0] for r in res]) * scale\n sparse = np.array([0.0] + [r[1] for r in res]) * scale\n mad = np.array([0.0] + [r[2] for r in res])\n resid = np.array([0.0] + [r[3] for r in res])\n frac = np.array([0.0] + [r[4] for r in res])\n v = np.maximum(dense, sparse)\n\n # Cuts: most pixels change and the flow cannot explain it (hard cut, flash, full-frame flip).\n # No speed from them: a blur sample cannot help there, and a raw read of one reached 1,187 px/f.\n cuts = [i for i in range(1, n_frames) if frac[i] > a.cut_frac and resid[i] > 0.5]\n v[cuts] = 0.0\n # Smaller changes the flow cannot explain (floods, flashes on part of the frame) keep their\n # speed, but a hard cut inside an act would let sub-frames straddle it, so those are reported.\n suspects = [i for i in range(1, n_frames) if mad[i] > a.cut_mad and resid[i] > 0.6 and i not in cuts]\n # One-frame spikes: real motion is smooth at 60 fps, so a pair more than --spike x faster than\n # both neighbours stands only when both trackers see it (a peaky whip does; an object entering\n # from off-frame, whose edge the dense flow misreads, does not). Otherwise take the larger neighbour.\n if a.spike > 0 and n_frames > 2:\n nb = np.maximum(np.r_[0.0, v[:-1]], np.r_[v[1:], 0.0])\n agree = np.minimum(dense, sparse) >= 0.7 * np.maximum(dense, sparse)\n v = np.where((v > a.spike * nb) & ~agree, nb, v)\n\n raw = v.copy() # per-pair speed, before the shutter straddle and the max filter: the pace report reads it\n speed = np.array([max(v[i], v[i + 1] if i + 1 \u003c n_frames else 0.0) for i in range(n_frames)])\n if a.maxfilter > 0:\n from scipy.ndimage import maximum_filter1d\n speed = maximum_filter1d(speed, size=2 * a.maxfilter + 1, mode='nearest')\n meta = dict(frames=n_frames, fps=fps, width=src_w, height=src_h, measured_s=round(time.time() - t0, 1), raw=raw)\n return speed, speed.copy(), cuts, suspects, meta\n\n\ndef reuse(a, log):\n \"\"\"The speed track of an earlier samples JSON, to re-decide groups without measuring again.\"\"\"\n try:\n d = json.load(open(a.video))\n sp = np.asarray(d['speed'], dtype=float)\n except (OSError, ValueError, KeyError) as e:\n sys.exit(f'measure-speed.py: cannot reuse {a.video}: {e}')\n off = int(d.get('offset', 0))\n if a.offset and a.offset != off:\n sys.exit(f'measure-speed.py: {a.video} starts at frame {off}; --offset {a.offset} does not match')\n a.offset = off\n speed = sp[off:]\n cuts = [c - off for c in d.get('cuts', [])]\n suspects = [c - off for c in d.get('suspect_cuts', [])]\n meta = {k: d.get(k) for k in ('frames', 'fps', 'width', 'height', 'sharp_s_per_frame')}\n meta['frames'] = len(speed)\n meta['raw'] = np.asarray(d['raw_speed'][off:], dtype=float) if d.get('raw_speed') else None\n meta['reused_from'] = a.video\n log(f' reusing the speed track of {a.video} ({len(speed)} frames)')\n return speed, np.asarray(d.get('flow_speed_track', speed), dtype=float), cuts, suspects, meta\n\n\n# Speed limits per pace (px/f at output size): ordinary moves, the share of fast frames, and the\n# frame while text is being read.\nPACE_LIMITS = {'calm': dict(p90=25.0, over40=0.06, read_median=3.0, read_peak=25.0),\n 'crisp': dict(p90=45.0, over40=0.15, read_median=3.0, read_peak=40.0)}\n\n\ndef pace_report(raw, still, fps):\n \"\"\"How fast the film moves: moving-frame percentiles, fast-frame shares, the longest near-still run.\"\"\"\n raw = np.asarray(raw, dtype=float)\n moving = raw[raw >= still]\n slow, run, longest, moves, inside = raw \u003c 2.0, 0, 0, 0, False\n for k, sl in enumerate(slow):\n run = run + 1 if sl else 0\n longest = max(longest, run)\n fast = raw[k] > 40\n moves += int(fast and not inside)\n inside = fast\n n = max(1, len(raw))\n return dict(p50=round(float(np.percentile(moving, 50)), 1) if len(moving) else 0.0,\n p90=round(float(np.percentile(moving, 90)), 1) if len(moving) else 0.0,\n over12=round(float((raw > 12).sum()) / n, 3), over25=round(float((raw > 25).sum()) / n, 3),\n over40=round(float((raw > 40).sum()) / n, 3), longest_under2_f=int(longest),\n longest_under2_s=round(longest / fps, 2) if fps else None, moves_over40=int(moves))\n\n\ndef read_windows(c):\n \"\"\"The frames in which a copy entry is read: its `read` windows (export-cues.ts writes the\n timeline's READ: an earlier fragment of a chain only for its own hold, so a chain's picture beat\n is not a read), or [resolved, out) from a cues.json written before them.\"\"\"\n read = c.get('read')\n if isinstance(read, list) and all(isinstance(w, (list, tuple)) and len(w) == 2 for w in read):\n return [(int(a), int(b)) for a, b in read]\n return [(int(c['resolved']), int(c.get('out', c['resolved'])))]\n\n\ndef copy_windows(path, raw, offset, limits):\n \"\"\"Speed inside each copy entry's reading windows (its `read` in a cues.json copy block).\"\"\"\n try:\n doc = json.load(open(path))\n except (OSError, ValueError) as e:\n sys.exit(f'measure-speed.py: cannot read --copy {path}: {e}')\n rows, warns = [], []\n for c in doc.get('copy') or []:\n for a0, b0 in read_windows(c):\n a, b = max(a0 - offset, 0), min(b0 - offset, len(raw))\n if b - a \u003c 2:\n continue\n seg = np.asarray(raw[a:b], dtype=float)\n row = dict(id=c.get('id'), text=c.get('text'), kind=c.get('kind', 'copy'), frames=[a + offset, b + offset],\n median=round(float(np.median(seg)), 1), peak=round(float(seg.max()), 1),\n peak_frame=int(a + int(np.argmax(seg)) + offset))\n rows.append(row)\n if limits and row['kind'] != 'ui' and (row['median'] > limits['read_median'] or row['peak'] > limits['read_peak']):\n warns.append(f\"while \\\"{row['text']}\\\" is read (f{row['frames'][0]}-{row['frames'][1]}) the frame moves \"\n f\"{row['median']} px/f (median), {row['peak']} at f{row['peak_frame']}: keep it under \"\n f\"{limits['read_median']:g} px/f with nothing over {limits['read_peak']:g} until the read is done\")\n return rows, warns\n\n\ndef main():\n a = parse_args()\n t0 = time.time()\n\n def log(msg):\n if not a.quiet:\n sys.stderr.write(msg + '\\n')\n\n if a.video.endswith('.json'):\n speed, flow_speed, cuts, suspects, meta = reuse(a, log)\n else:\n speed, flow_speed, cuts, suspects, meta = measure(a, log, t0)\n n_frames = meta['frames']\n total = a.offset + n_frames\n if a.analytic:\n speed = np.maximum(speed, load_analytic(a.analytic, total)[a.offset:])\n\n sfrac = a.shutter / 360.0\n groups = [1 if s \u003c a.still else int(min(a.max, max(a.min, math.ceil(sfrac * s / a.step)))) for s in speed]\n\n # --budget: cap the samples per frame so the total stays \u003c= budget x frames. The fastest frames\n # lose samples first; still frames stay at 1.\n cap = a.max\n budget = None\n capped = []\n if a.budget > 0:\n budget = {'multiple': a.budget, 'cap': None, 'uncapped_subframes': int(sum(groups))}\n while cap > a.min and sum(min(g, cap) for g in groups) > a.budget * n_frames:\n cap -= 1\n if cap \u003c a.max:\n capped = [i for i, g in enumerate(groups) if g > cap]\n groups = [min(g, cap) for g in groups]\n budget['cap'] = cap\n budget['met'] = sum(groups) \u003c= a.budget * n_frames\n floors = parse_floors(a.floor)\n for fa, fb, n in floors:\n for i in range(max(fa - a.offset, 0), min(fb - a.offset, n_frames - 1) + 1):\n groups[i] = max(groups[i], min(n, a.max))\n\n # Frames faster than the samples can cover would show stepped copies (\"ghosts\") over a long\n # smear. Unless --no-short-shutter, they get a shorter shutter instead, so the samples stay\n # --step px apart: a shorter, clean streak. Either way the move is too fast; the log says so.\n shutters = [a.shutter] * n_frames\n short = []\n for i, (s, g) in enumerate(zip(speed, groups)):\n if g > 1 and sfrac * s / g > a.step * 1.02:\n short.append(i)\n if not a.no_short_shutter:\n shutters[i] = round(max(a.min_shutter, 360.0 * a.step * g / s), 1)\n too_fast = [i for i, s in enumerate(speed) if s > a.too_fast]\n\n # A hard cut renders sharp. Its frame's shutter would otherwise reach back into the outgoing\n # shot (the temporal max filter gives it its neighbours' speed) and average the two shots into\n # one double-exposed frame. At an act boundary src/blur.ts already clamps the samples; inside an\n # act only this keeps the cut clean.\n for c in cuts:\n if 0 \u003c= c \u003c n_frames:\n groups[c] = 1\n shutters[c] = a.shutter\n short = [i for i in short if i not in set(cuts)]\n\n groups = [1] * a.offset + groups\n sub = int(sum(groups[a.offset:]))\n out = {'groups': groups, 'speed': [0.0] * a.offset + [round(float(s), 1) for s in speed], 'offset': a.offset,\n 'frames': n_frames, 'fps': meta.get('fps'), 'width': meta.get('width'), 'height': meta.get('height'),\n 'shutter': a.shutter, 'step': a.step, 'min': a.min, 'max': a.max, 'still': a.still,\n 'cuts': [c + a.offset for c in cuts], 'suspect_cuts': [c + a.offset for c in suspects],\n 'floors': [list(f) for f in floors], 'analytic': bool(a.analytic), 'subframes': sub,\n 'multiple': round(sub / n_frames, 2), 'peak': round(float(speed.max()), 1),\n 'peak_frame': int(np.argmax(speed)) + a.offset, 'flow_peak': round(float(np.max(flow_speed)), 1),\n 'budget': budget, 'capped': ranges_of(capped, a.offset),\n 'too_fast': ranges_of(too_fast, a.offset), 'too_fast_px_f': a.too_fast,\n 'short_shutter': ranges_of(short, a.offset) if not a.no_short_shutter else [],\n 'fastest_frames': [int(i) + a.offset for i in np.argsort(-speed, kind='stable')[:6] if speed[i] >= 12]}\n if not a.no_short_shutter and short:\n out['shutters'] = [a.shutter] * a.offset + shutters # read by src/blur.ts (per-frame shutter)\n for k in ('sharp_s_per_frame', 'reused_from', 'measured_s'):\n if meta.get(k) is not None:\n out[k] = meta[k]\n\n # Pace: how fast the film moves, and how still the frame is while text is read.\n raw = meta.get('raw')\n raw = np.asarray(speed if raw is None else raw, dtype=float)\n out['raw_speed'] = [0.0] * a.offset + [round(float(x), 1) for x in raw]\n copy_path = a.copy or (a.cues if a.cues and 'copy' in json.load(open(a.cues)) else None)\n pace = a.pace\n if not pace:\n for src in (copy_path, a.cues):\n if src and json.load(open(src)).get('pace') in PACE_LIMITS:\n pace = json.load(open(src))['pace']\n break\n limits = PACE_LIMITS.get(pace)\n rep = pace_report(raw, a.still, meta.get('fps') or 60)\n warns = []\n if limits and rep['p90'] > limits['p90']:\n warns.append(f\"90% of moving frames stay under {rep['p90']} px/f; a {pace} film keeps ordinary moves under \"\n f\"{limits['p90']:g} px/f (longer moves, shorter distances, or the speed in one or two moments)\")\n if limits and rep['over40'] > limits['over40']:\n warns.append(f\"{100 * rep['over40']:.1f}% of frames move faster than 40 px/f ({rep['moves_over40']} moves); \"\n f\"a {pace} film keeps it under {100 * limits['over40']:g}%\")\n out['pace'] = dict(rep, profile=pace, source='flow' if meta.get('raw') is not None else 'speed track')\n if copy_path:\n rows, cw = copy_windows(copy_path, raw, a.offset, limits)\n out['copy_speed'] = rows\n warns += cw\n out['pace_warnings'] = warns\n if a.cues and (out['suspect_cuts'] or out['cuts']):\n acts = json.load(open(a.cues)).get('acts', {})\n edges = [int(x['from']) for x in acts.values()]\n out['cuts_inside_acts'] = sorted(c for c in out['suspect_cuts'] + out['cuts'] if all(abs(c - e) > 2 for e in edges))\n os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)\n with open(a.out, 'w') as fh:\n json.dump(out, fh)\n\n g = np.array(groups[a.offset:])\n log(f'{n_frames} frames -> {sub} sub-frames ({sub / n_frames:.1f}x); still {int((g == 1).sum())}, '\n f'2-8: {int(((g > 1) & (g \u003c= 8)).sum())}, 9-24: {int(((g > 8) & (g \u003c= 24)).sum())}, 25+: {int((g > 24).sum())}; '\n f'peak {out[\"peak\"]:.0f} px/f at frame {out[\"peak_frame\"]}; {time.time() - t0:.0f}s')\n if out['cuts']:\n log(f' cuts (most pixels changed, flow cannot explain it; no speed taken): frames {out[\"cuts\"][:12]}'\n + (' ...' if len(out['cuts']) > 12 else ''))\n if out.get('cuts_inside_acts'):\n log(f' check frames {out[\"cuts_inside_acts\"][:12]}: large changes the flow cannot explain, away from act '\n 'boundaries. Measured hard cuts render with one sample; any other that is a hard cut must become an ACT '\n 'boundary, or its sub-frames blend the two shots (floods, flashes and zoom-throughs are fine).')\n if budget and capped:\n worst = float(max(speed[i] for i in capped))\n log(f' budget {a.budget:g}x: capped {len(capped)} frames at {cap} samples ({budget[\"uncapped_subframes\"]} -> {sub} '\n f'sub-frames); frames {out[\"capped\"][:8]}' + (' ...' if len(out['capped']) > 8 else '')\n + f'; the fastest ({worst:.0f} px/f) keep a {360.0 * a.step * cap / worst:.0f} deg shutter')\n if budget and not budget['met']:\n log(f' budget {a.budget:g}x cannot be met even at --min {a.min} samples per moving frame')\n if too_fast:\n log(f' TOO FAST FOR CLEAN BLUR: frames {out[\"too_fast\"][:8]}' + (' ...' if len(out['too_fast']) > 8 else '')\n + f' move faster than {a.too_fast:g} px/f (peak {out[\"peak\"]:.0f} at frame {out[\"peak_frame\"]}). '\n 'Blur smears that into a streak with visible copies; redesign the move: cut on the beat, a match cut, '\n 'a mask wipe, or a shorter distance (references/transitions.md).')\n if short:\n log(f' frames {out[\"short_shutter\"][:8] or ranges_of(short, a.offset)[:8]} need more than {cap} samples: '\n + ('they render with a shorter shutter so samples stay %gpx apart (a clean, shorter streak)' % a.step\n if not a.no_short_shutter else 'with --no-short-shutter they will show stepped copies'))\n pr = out['pace']\n log(f' pace{\" (\" + pace + \")\" if pace else \"\"}: moving p50 {pr[\"p50\"]} / p90 {pr[\"p90\"]} px/f; frames over 12/25/40 px/f '\n f'{100 * pr[\"over12\"]:.0f}/{100 * pr[\"over25\"]:.0f}/{100 * pr[\"over40\"]:.1f}%, {pr[\"moves_over40\"]} moves over 40; '\n f'longest run under 2 px/f {pr[\"longest_under2_f\"]} f')\n for row in out.get('copy_speed', []):\n log(f' read \"{row[\"text\"]}\" f{row[\"frames\"][0]}-{row[\"frames\"][1]}: median {row[\"median\"]}, peak {row[\"peak\"]} px/f')\n for w in warns:\n log(f' PACE: {w}')\n print(a.out)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":31898,"content_sha256":"9e5bb7fa4385d7ddf2f4efd9a046f24f54686da2f80b186a7d0af9fa12bdab6c"},{"filename":"scripts/new-film.sh","content":"#!/usr/bin/env bash\n# new-film.sh: start a film project from the cinetic starter.\n#\n# Copies assets/remotion-starter (or assets/hyperframes-starter) into \u003cdir>, sets the frame rate,\n# tempo, pace, size and name, copies every cinetic script into \u003cdir>/scripts so the project runs on\n# its own, then installs dependencies (or symlinks an existing node_modules with --link-modules).\n#\n# Usage:\n# bash scripts/new-film.sh \u003cdir> [--engine remotion|hyperframes] [--fps 60] [--bpm N]\n# [--pace calm|crisp] [--size 1920x1080] [--name my-film]\n# [--link-modules \u003cnode_modules>]\n# [--font \u003cname>]... [--browser \u003cchromium path>] [--no-install] [--force]\n#\n# --fps, --bpm one beat must be a whole number of frames (60*fps/bpm). At 60 fps:\n# 90 -> 40 f, 100 -> 36, 120 -> 30, 144 -> 25, 150 -> 24. Without --bpm the\n# starter keeps its own tempo (Remotion 100, HyperFrames 120), or 120 with\n# --pace crisp. The project's audio/score.json gets the same bpm.\n# --pace calm (default): text arrives as short fragments that take turns with the\n# picture and holds about 1-1.5 s; crisp: the denser launch-film tempo. Sets\n# PACE_NAME (src/timeline.ts, or motion.js), which the components, grid-check\n# and lint-film read.\n# --size WxH, e.g. 1920x1080, 1080x1920 (9:16), 1080x1080. The demo lays out from the short side.\n# --engine remotion (default): sets FPS/BPM/W/H in src/timeline.ts. hyperframes: sets them in\n# motion.js and rewrites index.html's root data-fps/-width/-height/-duration (TOTAL/FPS),\n# viewport and html/body size to match. Either way the starter's brand (palette,\n# typeface, mark, name) is marked cinetic:placeholder and lint-film.mjs fails\n# until it is replaced.\n# --link-modules link this node_modules instead of running npm install (offline sandboxes): a\n# symlink, or on Windows (Git Bash, MSYS2, Cygwin) a directory junction, because\n# ln -s there copies the whole folder unless Windows symlinks are enabled.\n# --font vendor a @fontsource-variable/\u003cname> family into the project (scripts/add-font.mjs:\n# npm pack into a temp folder, never an install into a shared node_modules) and set\n# FONT/FACES to it. Repeat for a mono. Later, run `node scripts/add-font.mjs \u003cname>`.\n# --browser bake a Chromium/headless-shell path into remotion.config.ts (env REMOTION_BROWSER\n# or CHROMIUM_PATH at render time also works, and wins).\n# --force allow a non-empty \u003cdir> (starter files overwrite same-named files).\n#\n# Exit codes: 0 created, 1 bad arguments or a failed step (the message says which).\nset -euo pipefail\n\nusage() { sed -n '2,/^set -euo/p' \"$0\" | sed '$d' | sed 's/^# \\{0,1\\}//'; }\ndie() { echo \"new-film: $*\" >&2; exit 1; }\n\nSKILL=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\nFONTS=()\nDIR=\"\"; ENGINE=remotion; FPS=60; BPM=\"\"; PACE=\"\"; SIZE=1920x1080; NAME=\"\"; LINK=\"\"; BROWSER=\"${CHROMIUM_PATH:-}\"; INSTALL=1; FORCE=0\n\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n -h|--help) usage; exit 0 ;;\n --engine) ENGINE=\"${2:?--engine needs a value}\"; shift 2 ;;\n --fps) FPS=\"${2:?--fps needs a value}\"; shift 2 ;;\n --bpm) BPM=\"${2:?--bpm needs a value}\"; shift 2 ;;\n --pace) PACE=\"${2:?--pace needs calm or crisp}\"; shift 2 ;;\n --size) SIZE=\"${2:?--size needs a value}\"; shift 2 ;;\n --name) NAME=\"${2:?--name needs a value}\"; shift 2 ;;\n --link-modules) LINK=\"${2:?--link-modules needs a path}\"; shift 2 ;;\n --browser) BROWSER=\"${2:?--browser needs a path}\"; shift 2 ;;\n --font) FONTS+=(\"${2:?--font needs a family name or package}\"); shift 2 ;;\n --no-install) INSTALL=0; shift ;;\n --force) FORCE=1; shift ;;\n -*) die \"unknown option $1 (see --help)\" ;;\n *) [[ -z \"$DIR\" ]] || die \"one target directory only (got '$DIR' and '$1')\"; DIR=\"$1\"; shift ;;\n esac\ndone\n\n[[ -n \"$DIR\" ]] || { usage; die \"missing \u003cdir>\"; }\n[[ \"$ENGINE\" == remotion || \"$ENGINE\" == hyperframes ]] || die \"--engine must be remotion or hyperframes\"\n[[ \"$FPS\" =~ ^[0-9]+$ && \"$FPS\" -gt 0 ]] || die \"--fps must be a positive integer\"\n[[ -z \"$PACE\" || \"$PACE\" == calm || \"$PACE\" == crisp ]] || die \"--pace must be calm or crisp\"\nSRC=\"$SKILL/assets/$ENGINE-starter\"\n[[ -d \"$SRC\" ]] || die \"starter not found: $SRC\"\nif [[ -z \"$BPM\" ]]; then\n # the starter's own tempo, unless the pace asks for the crisp one\n GRID=\"$SRC/src/timeline.ts\"; [[ \"$ENGINE\" == hyperframes ]] && GRID=\"$SRC/motion.js\"\n BPM=$(sed -nE 's/^[[:space:]]*(export )?const BPM = ([0-9.]+);.*/\\2/p' \"$GRID\" | head -n 1)\n [[ \"$PACE\" == crisp ]] && BPM=120\n [[ -n \"$BPM\" ]] || die \"could not read BPM from $GRID; pass --bpm\"\nfi\n[[ \"$BPM\" =~ ^[0-9]+([.][0-9]+)?$ ]] || die \"--bpm must be a number\"\n[[ \"$SIZE\" =~ ^([0-9]+)x([0-9]+)$ ]] || die \"--size must look like 1920x1080\"\nW=\"${BASH_REMATCH[1]}\"; H=\"${BASH_REMATCH[2]}\"\n(( W % 2 == 0 && H % 2 == 0 )) || die \"--size must be even in both dimensions (yuv420p needs it)\"\n\n# The whole system assumes a beat is a whole number of frames: cues on the grid, sound on the frame.\nif ! node -e \"const b=60*$FPS/$BPM; process.exit(Math.abs(b-Math.round(b))\u003c1e-9?0:1)\"; then\n OK=$(node -e \"const r=[];for(let b=60;b\u003c=200;b++){const f=60*$FPS/b;if(Number.isInteger(f))r.push(b+' ('+f+' f)')}console.log(r.join(', '))\")\n die \"at $FPS fps, $BPM BPM gives a beat of $(node -e \"console.log((60*$FPS/$BPM).toFixed(3))\") frames. Pick a BPM with a whole beat: $OK\"\nfi\n\nif [[ -e \"$DIR\" ]]; then\n [[ -d \"$DIR\" ]] || die \"$DIR exists and is not a directory\"\n if [[ -n \"$(ls -A \"$DIR\" 2>/dev/null)\" && \"$FORCE\" != 1 ]]; then die \"$DIR is not empty (use --force to copy into it anyway)\"; fi\nfi\n[[ -z \"$NAME\" ]] && NAME=\"$(basename \"$DIR\")\"\nPKG=$(echo \"$NAME\" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^[-._]+//; s/-+$//')\n[[ -n \"$PKG\" ]] || PKG=film\n\nmkdir -p \"$DIR\"\nDIR=\"$(cd \"$DIR\" && pwd)\"\n# Copy the starter, leaving out anything installed or rendered.\n(cd \"$SRC\" && tar --exclude=node_modules --exclude=out --exclude=__pycache__ -cf - .) | (cd \"$DIR\" && tar -xf -)\n\n# Substitute a `const NAME = value;` line; fail loudly if the starter no longer has it.\nset_const() { # file name value\n local file=\"$1\" name=\"$2\" value=\"$3\"\n grep -Eq \"^(export )?const $name(: [^=]+)? = [^;]+;\" \"$file\" || die \"$file has no 'const $name = ...;' line to set\"\n sed -i.bak -E \"s|^((export )?const $name(: [^=]+)? = )[^;]+;|\\1$value;|\" \"$file\" && rm -f \"$file.bak\"\n}\n\nif [[ \"$ENGINE\" == remotion ]]; then\n TL=\"$DIR/src/timeline.ts\"\n set_const \"$TL\" FPS \"$FPS\"; set_const \"$TL\" BPM \"$BPM\"; set_const \"$TL\" W \"$W\"; set_const \"$TL\" H \"$H\"\n [[ -n \"$PACE\" ]] && set_const \"$TL\" PACE_NAME \"'$PACE'\"\n sed -i.bak -E \"s|\\\"name\\\": \\\"[^\\\"]*\\\"|\\\"name\\\": \\\"$PKG\\\"|; s|[0-9]+x[0-9]+@[0-9]+|${W}x${H}@${FPS}|g\" \"$DIR/package.json\" && rm -f \"$DIR/package.json.bak\"\n if [[ -n \"$BROWSER\" ]]; then\n [[ -x \"$BROWSER\" ]] || echo \"new-film: warning: --browser $BROWSER is not an executable file here\" >&2\n set_const \"$DIR/remotion.config.ts\" PINNED_BROWSER \"'$BROWSER'\"\n fi\nelse\n # HyperFrames: the grid lives in motion.js (FPS, BPM, W, H); index.html repeats the size, fps and\n # length on the root element, the viewport meta and the html/body box. Set all of them, then\n # derive data-duration from timeline.js (TOTAL / FPS) so the render neither cuts nor freezes.\n MJ=\"$DIR/motion.js\"\n [[ -f \"$MJ\" && -f \"$DIR/timeline.js\" && -f \"$DIR/index.html\" ]] || die \"HyperFrames starter needs motion.js, timeline.js and index.html\"\n for kv in \"FPS=$FPS\" \"BPM=$BPM\" \"W=$W\" \"H=$H\" ${PACE:+\"PACE_NAME='$PACE'\"}; do\n k=\"${kv%%=*}\"; v=\"${kv#*=}\"\n grep -Eq \"^[[:space:]]*const $k = [^;]+;\" \"$MJ\" || die \"motion.js has no 'const $k = ...;' line to set\"\n sed -i.bak -E \"s|^([[:space:]]*const $k = )[^;]+;|\\1$v;|\" \"$MJ\" && rm -f \"$MJ.bak\"\n done\n DIR=\"$DIR\" W=\"$W\" H=\"$H\" node - \u003c\u003c'JS' || die \"could not update index.html from motion.js/timeline.js\"\nconst fs = require('fs'), path = require('path'), vm = require('vm');\nconst dir = process.env.DIR, W = +process.env.W, H = +process.env.H;\nconst ctx = {}; ctx.window = ctx; vm.createContext(ctx);\nfor (const f of ['motion.js', 'timeline.js']) vm.runInContext(fs.readFileSync(path.join(dir, f), 'utf8'), ctx, { filename: f });\nconst { FPS } = ctx.MOTION, { TOTAL } = ctx.FILM;\nconst dur = +(TOTAL / FPS).toFixed(6);\nconst file = path.join(dir, 'index.html');\nlet html = fs.readFileSync(file, 'utf8');\nconst root = (html.match(/\u003c[a-z][^>]*\\bdata-composition-id\\s*=[^>]*>/is) || [])[0];\nif (!root) throw new Error('index.html: no element with data-composition-id');\nconst old = (root.match(/\\bdata-duration\\s*=\\s*\"([^\"]*)\"/) || [])[1];\nconst setAttr = (tag, name, v) => (new RegExp(`\\\\b${name}\\\\s*=\\\\s*\"[^\"]*\"`).test(tag) ? tag.replace(new RegExp(`\\\\b${name}\\\\s*=\\\\s*\"[^\"]*\"`), `${name}=\"${v}\"`) : tag.replace(/\\s*\\/?>$/, ` ${name}=\"${v}\">`));\nlet tag = root;\nfor (const [n, v] of [['data-fps', FPS], ['data-width', W], ['data-height', H], ['data-duration', dur]]) tag = setAttr(tag, n, v);\nhtml = html.replace(root, tag);\n// clips that ran the whole film (same data-duration as the old root) keep running the whole film\nif (old !== undefined) html = html.replace(new RegExp(`(\\\\bdata-duration\\\\s*=\\\\s*\")${old.replace('.', '\\\\.')}(\")`, 'g'), `$1${dur}$2`);\nhtml = html.replace(/(\u003cmeta\\s+name=\"viewport\"\\s+content=\")[^\"]*(\")/i, `$1width=${W}, height=${H}$2`);\nhtml = html.replace(/(html,\\s*body\\s*\\{[^}]*?\\bwidth:\\s*)\\d+px/, `$1${W}px`).replace(/(html,\\s*body\\s*\\{[^}]*?\\bheight:\\s*)\\d+px/, `$1${H}px`);\nfs.writeFileSync(file, html);\nconsole.log(`new-film: index.html root ${W}x${H} @ ${FPS} fps, data-duration ${dur} s (TOTAL ${TOTAL} f)`);\nJS\n [[ -f \"$DIR/package.json\" ]] && sed -i.bak -E \"s|\\\"name\\\": \\\"[^\\\"]*\\\"|\\\"name\\\": \\\"$PKG\\\"|\" \"$DIR/package.json\" && rm -f \"$DIR/package.json.bak\"\nfi\n\n# score.py refuses a score whose bpm differs from the timeline's: keep audio/score.json on the grid.\nif [[ -f \"$DIR/audio/score.json\" ]]; then\n sed -i.bak -E \"s|(\\\"bpm\\\"[[:space:]]*:[[:space:]]*)[0-9.]+|\\1$BPM|\" \"$DIR/audio/score.json\" && rm -f \"$DIR/audio/score.json.bak\"\nfi\n\n# The project carries its own copy of every script, so npm scripts use local paths (not the\n# skill's own tests in scripts/test/).\nmkdir -p \"$DIR/scripts\"\n(cd \"$SKILL/scripts\" && tar --exclude=__pycache__ --exclude='*.pyc' --exclude=./test -cf - .) | (cd \"$DIR/scripts\" && tar -xf -)\nchmod +x \"$DIR\"/scripts/*.sh 2>/dev/null || true\n# pick.py draws from the technique library; the project keeps a copy so it runs from here too\nLIB=\"$SKILL/assets/library/techniques.json\"; [[ -f \"$LIB\" ]] || LIB=\"$SKILL/scripts/library/techniques.json\"\nif [[ -f \"$LIB\" ]]; then mkdir -p \"$DIR/scripts/library\" && cp \"$LIB\" \"$DIR/scripts/library/\"; fi\n\nif [[ -n \"$LINK\" ]]; then\n [[ -d \"$LINK\" ]] || die \"--link-modules: $LINK is not a directory\"\n rm -rf \"$DIR/node_modules\"\n TARGET=\"$(cd \"$LINK\" && pwd)\"\n case \"$(uname -s)\" in\n MINGW*|MSYS*|CYGWIN*)\n # ln -s copies here (hundreds of MB) unless Windows symlinks are enabled: make a junction,\n # which needs no rights and acts like a symlink for node and npm.\n MSYS_NO_PATHCONV=1 cmd /c mklink /J \"$(cygpath -w \"$DIR/node_modules\")\" \"$(cygpath -w \"$TARGET\")\" >/dev/null \\\n || die \"--link-modules: could not make a junction node_modules -> $TARGET (cmd /c mklink /J)\"\n ;;\n *) ln -s \"$TARGET\" \"$DIR/node_modules\" ;;\n esac\n [[ -d \"$DIR/node_modules\" ]] || die \"--link-modules: $DIR/node_modules was not created\"\n echo \"new-film: linked node_modules -> $LINK\"\n # npm's postinstall does not run for a linked folder; run the starter's setup step directly\n if [[ -f \"$DIR/setup.mjs\" ]]; then\n (cd \"$DIR\" && node setup.mjs) || echo \"new-film: warning: setup.mjs could not copy its files from the linked node_modules (npm install in $DIR fixes it)\" >&2\n fi\nelif [[ \"$INSTALL\" == 1 && -f \"$DIR/package.json\" ]]; then\n (cd \"$DIR\" && npm install --no-audit --no-fund) || die \"npm install failed in $DIR (rerun it there, or use --link-modules)\"\nfi\n\nfor font in ${FONTS[@]+\"${FONTS[@]}\"}; do\n node \"$DIR/scripts/add-font.mjs\" \"$font\" --dir \"$DIR\" || die \"--font $font: could not vendor it (see above)\"\ndone\n\nBEAT=$(node -e \"console.log(60*$FPS/$BPM)\")\necho \"new-film: created $DIR ($ENGINE, ${W}x${H} @ $FPS fps, $BPM BPM = $BEAT f per beat${PACE:+, $PACE pace})\"\nif [[ \"$ENGINE\" == remotion ]]; then\n cat \u003c\u003cEOF\nnext:\n cd $DIR\n npm run check # tsc, lint-film, grid-check: fails on purpose until the placeholder brand\n # (palette, typeface, mark and name: cinetic:placeholder in src/brand and\n # COPY) is replaced; see brand-and-color.md\n npm run stills # style frames in out/stills/ - look at them\n npm run studio # iterate act by act (Acts folder)\n npm run cues && npm run kit && npm run audio && npm run render:preview\n npm run listen # earshot's ears on the soundtrack: out/qa/listen.json + sound_sheet.png\nEOF\nelse\n cat \u003c\u003cEOF\nnext:\n cd $DIR\n node scripts/lint-film.mjs . # cinetic bans; fails on purpose until the placeholder palette\n # and font in index.html are replaced (references/brand-and-color.md)\n node scripts/grid-check.ts timeline.js # the grid, the reads and the text flow (Node 22.18+;\n # else npx -y tsx scripts/grid-check.ts timeline.js)\n npm run preview # HyperFrames Studio\n npm run cues && npm run kit && npm run audio && npm run render:preview # then: npm run render\n npm run listen # earshot's ears on the soundtrack: out/qa/listen.json + sound_sheet.png\nEOF\nfi\n# the effects come from earshot's kit (audio/kit.json): say now whether this machine can render it\nif [[ -f \"$DIR/audio/kit.json\" ]] && command -v python3 >/dev/null; then\n (cd \"$DIR\" && python3 scripts/audio/kit.py where 2>/dev/null | tail -n 1 | sed 's/^kit:/sound:/') || true\nfi\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":14186,"content_sha256":"abc20d103515e0d39d733d55288f65c9a03c4febac9ce2a8806906c4e9c8d7d2"},{"filename":"scripts/outline-text.py","content":"#!/usr/bin/env python3\n\"\"\"Outline a line of text as SVG path data, shaped the way the browser sets it (kerning, tracking,\na variable font's weight), so a wordmark ships as vector shapes that need no font installed.\n\nIt decodes the font (woff2, ttf or otf, variable or static), shapes the text with HarfBuzz at\nthe requested weight, adds CSS-style letter-spacing after every glyph, and draws each glyph's\noutline at the requested size. The output is JSON: the path (baseline at y = 0, y pointing down,\nx from 0 at the first glyph's origin), the advance width, and the ink box. scripts/brand-svg.ts\nuses it for the lockup SVGs; use it alone for any lettering that must leave the browser.\n\nUsage:\n python3 scripts/outline-text.py --font node_modules/@fontsource-variable/hanken-grotesk/files/hanken-grotesk-latin-wght-normal.woff2 \\\\\n --text tarn --weight 440 --track -0.015 --size 200\n python3 scripts/outline-text.py --font brand.otf --text \"tarn\" --size 200 --svg out/wordmark.svg\n\nNeeds: fonttools, brotli (for woff2) and uharfbuzz (pip install fonttools brotli uharfbuzz).\nExit 0 on success, 1 on a font or shaping error, 2 on missing modules.\n\"\"\"\nimport argparse\nimport io\nimport json\nimport sys\n\n\ndef main():\n ap = argparse.ArgumentParser(description=__doc__.split('\\n\\n')[0], formatter_class=argparse.RawDescriptionHelpFormatter)\n ap.add_argument('--font', required=True, help='font file: .woff2, .woff, .ttf or .otf')\n ap.add_argument('--text', required=True)\n ap.add_argument('--size', type=float, default=100, help='font size in px (the em)')\n ap.add_argument('--weight', type=float, help=\"wght axis value for a variable font (e.g. 440)\")\n ap.add_argument('--track', type=float, default=0, help='letter-spacing in em, as in CSS (e.g. -0.03)')\n ap.add_argument('--svg', help='also write a standalone SVG of the text, cropped to its ink')\n ap.add_argument('--fill', default='#000000', help='fill for --svg')\n a = ap.parse_args()\n try:\n from fontTools.ttLib import TTFont\n from fontTools.pens.svgPathPen import SVGPathPen\n from fontTools.pens.transformPen import TransformPen\n from fontTools.pens.boundsPen import BoundsPen\n import uharfbuzz as hb\n except ImportError as e:\n print(f'outline-text.py: {e}; pip install fonttools brotli uharfbuzz', file=sys.stderr)\n return 2\n try:\n font = TTFont(a.font)\n buf = io.BytesIO()\n font.flavor = None # woff2 -> sfnt bytes that HarfBuzz can read\n font.save(buf)\n data = buf.getvalue()\n font = TTFont(io.BytesIO(data))\n except Exception as e: # noqa: BLE001 - fontTools raises many types\n print(f'outline-text.py: cannot read {a.font}: {e}', file=sys.stderr)\n return 1\n axes = {ax.axisTag: (ax.minValue, ax.maxValue) for ax in font['fvar'].axes} if 'fvar' in font else {}\n loc = {}\n if a.weight is not None and 'wght' in axes:\n lo, hi = axes['wght']\n loc['wght'] = min(max(a.weight, lo), hi)\n if loc['wght'] != a.weight:\n print(f'outline-text.py: weight {a.weight:g} is outside the font axis {lo:g}-{hi:g}; using {loc[\"wght\"]:g}', file=sys.stderr)\n glyphs = font.getGlyphSet(location=loc or None)\n order = font.getGlyphOrder()\n upem = font['head'].unitsPerEm\n\n face = hb.Face(data)\n hbfont = hb.Font(face)\n if loc:\n hbfont.set_variations(loc)\n hbuf = hb.Buffer()\n hbuf.add_str(a.text)\n hbuf.guess_segment_properties()\n hb.shape(hbfont, hbuf, {'kern': True, 'liga': True})\n\n k = a.size / upem\n track = a.track * upem # CSS adds letter-spacing after every character\n pen = SVGPathPen(glyphs, ntos=lambda v: f'{v:.2f}'.rstrip('0').rstrip('.'))\n bounds = BoundsPen(glyphs)\n x = 0.0\n for info, pos in zip(hbuf.glyph_infos, hbuf.glyph_positions):\n name = order[info.codepoint]\n t = (k, 0, 0, -k, (x + pos.x_offset) * k, -pos.y_offset * k) # y down, baseline at 0\n glyphs[name].draw(TransformPen(pen, t))\n glyphs[name].draw(TransformPen(bounds, t))\n x += pos.x_advance + track\n advance = (x - track) * k # the ink ends one tracking before CSS's box does\n if bounds.bounds is None:\n print('outline-text.py: the text has no ink', file=sys.stderr)\n return 1\n x0, y0, x1, y1 = bounds.bounds\n out = dict(d=pen.getCommands(), size=a.size, weight=loc.get('wght'), track=a.track, advance=round(advance, 2),\n ink=[round(v, 2) for v in (x0, y0, x1, y1)], ascent=round(-y0, 2), descent=round(y1, 2),\n units_per_em=upem, text=a.text, font=a.font)\n if a.svg:\n pad = a.size * 0.02\n vb = f'{x0 - pad:.2f} {y0 - pad:.2f} {x1 - x0 + 2 * pad:.2f} {y1 - y0 + 2 * pad:.2f}'\n with open(a.svg, 'w') as fh:\n fh.write(f'\u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{vb}\">\u003cpath fill=\"{a.fill}\" d=\"{out[\"d\"]}\"/>\u003c/svg>\\n')\n print(json.dumps(out))\n return 0\n\n\nif __name__ == '__main__':\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5004,"content_sha256":"fcaab966ccfc743e81a5ce0e8044abd65cd3f7f038f8e7710abe3f0af0738a67"},{"filename":"scripts/palette.py","content":"#!/usr/bin/env python3\n\"\"\"Build a film palette from one accent, check its contrast, or measure accent coverage on a still.\n\nNeutrals come out tinted toward the accent's hue (or cool on request), for a light or a dark\nstage, each with its OKLCH, hex and contrast against the stage, ready to paste into\nsrc/brand/tokens.ts. The checks are the ones in references/brand-and-color.md:\n - the hard bans (SKILL.md, brand-and-color.md §8): an accent or paper that is orange/amber,\n beige/cream, purple/violet/indigo or neon is refused (exit 1, no palette) unless the user's\n brand supplied it (--brand-supplied; record it in BRIEF.md). The regions, in OKLCH:\n orange/amber 31 \u003c= h \u003c 96 and C >= 0.05 (orange, amber, gold, brass, brown, coral)\n beige/cream 31 \u003c= h \u003c 118, L >= 0.70 and 0.007 \u003c= C \u003c 0.10 (beige, cream, tan, sand)\n purple 270 \u003c= h \u003c 340 and C >= 0.02 (purple, violet, indigo, lavender, magenta)\n neon C >= 0.27; or L >= 0.85 and C >= 0.15; or L >= 0.80 and C >= 0.18\n (yellows, 96 \u003c= h \u003c 118: only L >= 0.91 with C >= 0.15)\n - statements (ink on the stage) >= 7:1; secondary text (mute) >= 4.5:1; graphics (accent) >= 3:1\n - accent chroma >= 0.08 (below that it reads as a grey, not a signal)\n - the accent's lightness band for the stage: 0.50-0.65 on a light stage, 0.70-0.82 on a dark\n one (a yellow, which only works on a dark stage: 0.84-0.90)\n\nUsage:\n python3 scripts/palette.py --accent '#C92F33' # light stage, tinted to the accent\n python3 scripts/palette.py --accent 'oklch(0.86 0.165 100)' --stage dark # dark stage, a yellow\n python3 scripts/palette.py --accent '#1F86CD' --temp cool --json out/qa/palette.json\n python3 scripts/palette.py --accent '#1F86CD' --paper '#F6F7F9' # check your own paper\n python3 scripts/palette.py --accent '#F26B1D' --brand-supplied # the user's orange: allowed\n python3 scripts/palette.py --cover out/stills/poster.png --accent '#C92F33' # accent share of pixels\n\n--temp: neutral (default) tints the greys with the accent's hue at a chroma too low to read as\nwarm; cool uses hue 250. warm (hue 75) makes a beige paper, so it needs --brand-supplied.\n--stage-hue H [--stage-chroma C]: give the stage and the dark neutrals a hue from the brand's world\n(pine-black 160, a green-black 145, a deep teal 200, an ink-navy with body 235) at chroma C\n(default 0.014 on a dark stage, 0.008 on a light one); type and light panels stay near-neutral.\nA stage hue in the purple range (270-340) is refused, and one in the warm range (31-118) is\nwarned about (it slides into brown or beige).\nA dark stage in the cool blue-grey band (h 235-270, C \u003c 0.02) gets a note: it is the stock\ndev-tool dark theme and reads as \"a screenshot of a tool\" before anything moves.\nExit 0 when every check passes, 1 when one fails or a colour is refused, 2 on bad input.\n\"\"\"\nimport argparse\nimport json\nimport math\nimport re\nimport sys\n\n\ndef srgb_to_lin(c):\n return c / 12.92 if c \u003c= 0.04045 else ((c + 0.055) / 1.055) ** 2.4\n\n\ndef lin_to_srgb(c):\n return 12.92 * c if c \u003c= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055\n\n\ndef oklch_to_lin(L, C, h):\n a, b = C * math.cos(math.radians(h)), C * math.sin(math.radians(h))\n l_ = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3\n m_ = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3\n s_ = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3\n return (4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,\n -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,\n -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_)\n\n\ndef lin_to_oklch(r, g, b):\n l_ = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3)\n m_ = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3)\n s_ = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3)\n L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_\n A = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_\n B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_\n return L, math.hypot(A, B), math.degrees(math.atan2(B, A)) % 360\n\n\ndef in_gamut(lin):\n return all(-1e-4 \u003c= v \u003c= 1 + 1e-4 for v in lin)\n\n\ndef oklch_hex(L, C, h):\n \"\"\"Hex for an OKLCH colour, reducing chroma until it fits sRGB. Returns (hex, C actually used).\"\"\"\n while C > 0 and not in_gamut(oklch_to_lin(L, C, h)):\n C = max(0.0, C - 0.002)\n rgb = [round(255 * lin_to_srgb(min(1.0, max(0.0, v)))) for v in oklch_to_lin(L, C, h)]\n return '#' + ''.join(f'{v:02X}' for v in rgb), C\n\n\ndef parse_colour(s):\n s = s.strip()\n m = re.fullmatch(r'#?([0-9a-fA-F]{6})', s)\n if m:\n x = m.group(1)\n return lin_to_oklch(*[srgb_to_lin(int(x[i:i + 2], 16) / 255) for i in (0, 2, 4)])\n m = re.fullmatch(r'oklch\\(\\s*([\\d.]+)(%?)\\s+([\\d.]+)\\s+([\\d.]+)\\s*\\)', s)\n if m:\n L = float(m.group(1)) / (100 if m.group(2) else 1)\n return L, float(m.group(3)), float(m.group(4))\n raise ValueError(f'cannot read colour {s!r} (want #RRGGBB or oklch(L C h))')\n\n\n# The hard-ban regions (SKILL.md \"Hard bans\"; references/brand-and-color.md §8). lint-film.mjs\n# carries the same numbers: change them in both places.\nYELLOW = (96.0, 118.0)\n\n\ndef banned(L, C, h):\n \"\"\"Which hard-ban region an OKLCH colour falls in, or None. Order matters: neon, then the\n light low-chroma warms (beige), then orange/amber, then purple.\"\"\"\n if YELLOW[0] \u003c= h \u003c YELLOW[1]: # yellow is light by nature: neon only at highlighter lightness\n neon = L >= 0.91 and C >= 0.15\n else:\n neon = (L >= 0.85 and C >= 0.15) or (L >= 0.80 and C >= 0.18)\n if neon or C >= 0.27:\n return 'neon (very bright and saturated)'\n if 31 \u003c= h \u003c 118 and L >= 0.70 and 0.007 \u003c= C \u003c 0.10:\n return 'beige/cream (warm, light, low chroma: beige, cream, tan, sand)'\n if 31 \u003c= h \u003c 96 and C >= 0.05:\n return 'orange/amber (orange, amber, gold, brass, brown, coral)'\n if 270 \u003c= h \u003c 340 and C >= 0.02:\n return 'purple/violet/indigo'\n return None\n\n\ndef luminance(c):\n \"\"\"WCAG relative luminance of '#RRGGBB', 'RRGGBB', '#RGB' or an (r, g, b) tuple of 0-255 ints.\"\"\"\n if isinstance(c, (tuple, list)):\n rgb = [v / 255 for v in c[:3]]\n else:\n h = str(c).strip().lstrip('#')\n if len(h) == 3:\n h = ''.join(ch * 2 for ch in h)\n if len(h) != 6:\n raise ValueError(f'not a hex colour: {c!r}')\n rgb = [int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)]\n return sum(w * srgb_to_lin(v) for w, v in zip((0.2126, 0.7152, 0.0722), rgb))\n\n\ndef contrast(a, b):\n la, lb = sorted((luminance(a), luminance(b)), reverse=True)\n return (la + 0.05) / (lb + 0.05)\n\n\ndef cover(img, accent_hex, radius=0.08):\n import cv2\n import numpy as np\n im = cv2.imread(img)\n if im is None:\n raise ValueError(f'cannot read image {img}')\n rgb = cv2.cvtColor(im, cv2.COLOR_BGR2RGB).astype(np.float32) / 255\n lin = np.where(rgb \u003c= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)\n M1 = np.array([[.4122214708, .5363325363, .0514459929], [.2119034982, .6806995451, .1073969566], [.0883024619, .2817188376, .6299787005]])\n M2 = np.array([[.2104542553, .7936177850, -.0040720468], [1.9779984951, -2.4285922050, .4505937099], [.0259040371, .7827717662, -.8086757660]])\n lab = np.cbrt(lin @ M1.T) @ M2.T\n ref = np.array([srgb_to_lin(int(accent_hex[i:i + 2], 16) / 255) for i in (1, 3, 5)], np.float32)\n ref_lab = np.cbrt(ref @ M1.T) @ M2.T\n return float((np.linalg.norm(lab - ref_lab, axis=-1) \u003c radius).mean() * 100)\n\n\ndef main():\n ap = argparse.ArgumentParser(description=__doc__.split('\\n\\n')[0], formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=__doc__.split('\\n\\n', 1)[1])\n ap.add_argument('--accent', required=True, help=\"the one accent: '#RRGGBB' or 'oklch(L C h)'\")\n ap.add_argument('--stage', choices=['light', 'dark'], default='light', help='the ground most shots sit on')\n ap.add_argument('--temp', choices=['neutral', 'cool', 'warm'], default='neutral',\n help='tint of the neutrals (warm makes a beige paper: needs --brand-supplied)')\n ap.add_argument('--stage-hue', type=float, help=\"the stage's own hue from the brand's world (OKLCH h, e.g. 160 for pine-black)\")\n ap.add_argument('--stage-chroma', type=float, help='chroma for --stage-hue (default 0.014 dark, 0.008 light)')\n ap.add_argument('--paper', help=\"use this paper instead of the generated one (hex or oklch), and check it\")\n ap.add_argument('--brand-supplied', action='store_true',\n help=\"the user's brand supplied the accent or paper: skip the hard-ban refusal (record it in BRIEF.md)\")\n ap.add_argument('--meaning', default='\u003cone word>', help='what the accent means, for the token comment')\n ap.add_argument('--cover', metavar='IMG', help='only measure the share of pixels within OKLab 0.08 of the accent')\n ap.add_argument('--json', help='also write the palette and checks here')\n a = ap.parse_args()\n try:\n aL, aC, ah = parse_colour(a.accent)\n except ValueError as e:\n print(f'palette.py: {e}', file=sys.stderr)\n return 2\n acc_hex, _ = oklch_hex(aL, aC, ah)\n paper_in = None\n if a.paper:\n try:\n paper_in = parse_colour(a.paper)\n except ValueError as e:\n print(f'palette.py: {e}', file=sys.stderr)\n return 2\n\n if not a.cover:\n # the hard bans: refuse before building anything, unless the brand supplied the colour\n refusals = []\n why = banned(aL, aC, ah)\n if why:\n refusals.append(f'accent {acc_hex} (OKLCH {aL:.2f} {aC:.3f} {ah:.0f}) is {why}')\n if a.temp == 'warm':\n refusals.append('--temp warm makes a beige paper')\n if paper_in:\n why = banned(*paper_in)\n if why:\n refusals.append(f'paper {a.paper} (OKLCH {paper_in[0]:.2f} {paper_in[1]:.3f} {paper_in[2]:.0f}) is {why}')\n if refusals and not a.brand_supplied:\n for r in refusals:\n print(f'palette.py: refused: {r}.', file=sys.stderr)\n print(' That is a hard ban when you invent the look (SKILL.md \"Hard bans\"; references/brand-and-color.md §8).\\n'\n ' Choose the accent from red, green, teal, blue or yellow, and keep the paper neutral or cool.\\n'\n \" If the user's brand supplied this colour, pass --brand-supplied and record it in BRIEF.md.\", file=sys.stderr)\n return 1\n for r in refusals:\n print(f'palette.py: brand-supplied: {r}; record it in BRIEF.md so the critics accept it.', file=sys.stderr)\n # not a ban, but the fastest way to look like a template: framework-default blues\n if not a.brand_supplied and 252 \u003c= ah \u003c= 268 and aC >= 0.17 and 0.50 \u003c= aL \u003c= 0.66:\n print(f'palette.py: note: accent {acc_hex} sits in the framework-default blue family (#3B82F6/#2563EB). '\n 'It reads as \"default\" before anything moves. Derive the accent from the brand\\'s world '\n '(references/brand-and-color.md §9: shift the hue, lower the chroma, or pick another family).', file=sys.stderr)\n\n if a.cover:\n try:\n pct = cover(a.cover, acc_hex)\n except ValueError as e:\n print(f'palette.py: {e}', file=sys.stderr)\n return 2\n ok = pct \u003c= 8\n print(f'accent {acc_hex} covers {pct:.2f}% of {a.cover} ({\"ok\" if ok else \"over 8%: fine only on the one designed flood\"})')\n return 0 if ok else 1\n\n hue = {'neutral': ah, 'cool': 250.0, 'warm': 75.0}[a.temp]\n tint = 2.2 if a.temp == 'warm' else 1.0 # warm papers carry a little more chroma\n stage_hue = None\n if a.stage_hue is not None:\n stage_hue = a.stage_hue % 360\n if 270 \u003c= stage_hue \u003c 340 and not a.brand_supplied:\n print(f'palette.py: refused: --stage-hue {stage_hue:.0f} is in the purple range (270-340), '\n 'a hard ban (SKILL.md, \"Hard bans\"). Pick a green, teal, blue or neutral stage.', file=sys.stderr)\n return 1\n if 31 \u003c= stage_hue \u003c 118:\n print(f'palette.py: note: --stage-hue {stage_hue:.0f} is warm; a tinted stage there reads as brown or '\n 'beige. Prefer --temp neutral (a true charcoal or a neutral paper).', file=sys.stderr)\n stage_C = a.stage_chroma if a.stage_chroma is not None else (0.014 if a.stage == 'dark' else 0.008)\n # Toward a warm hue (a red or a yellow accent) the neutrals stay neutral: C \u003c= 0.004, and \u003c= 0.003\n # for light ones, so they never read as warm and 8-bit rounding can never push the paper or a\n # light grey into the beige region (C >= 0.007).\n warm_hue = 20 \u003c= hue \u003c 125 and a.temp != 'warm'\n if a.stage == 'light':\n spec = [('paper', 0.975, 0.004 * tint, 'the stage'), ('mist', 0.945, 0.005 * tint, 'canvas behind the product'),\n ('line', 0.885, 0.006, 'hairlines, >= 1.5 px on screen'), ('mute', 0.52, 0.012, 'secondary text'),\n ('ink2', 0.23, 0.008, 'raised dark surfaces'), ('ink', 0.17, 0.007, 'type and marks')]\n stage_key, text_key, band = 'paper', 'ink', (0.50, 0.65)\n else:\n spec = [('ink', 0.165, 0.008 * tint, 'the stage'), ('ink2', 0.215, 0.009 * tint, 'raised surfaces on the stage'),\n ('line', 0.31, 0.010, 'hairlines, >= 1.5 px on screen'), ('mute', 0.72, 0.012, 'secondary text'),\n ('mist', 0.90, 0.006, 'light panels, if any'), ('paper', 0.965, 0.004 * tint, 'type and marks')]\n stage_key, text_key, band = 'ink', 'paper', (0.84, 0.90) if YELLOW[0] \u003c= ah \u003c YELLOW[1] else (0.70, 0.82)\n tokens = {}\n for name, L, C, job in spec:\n h_use = hue\n if stage_hue is not None:\n # the stage and its surfaces carry the world's hue; type and light panels stay near-neutral\n carries = name in (('ink', 'ink2', 'line') if a.stage == 'dark' else ('paper', 'mist', 'line'))\n h_use = stage_hue\n C = stage_C * (1.0 if name == stage_key else 0.8) if carries else min(C, 0.006)\n elif warm_hue:\n C = min(C, 0.003 if L >= 0.7 else 0.004)\n hx, c_used = oklch_hex(L, C, h_use)\n tokens[name] = dict(hex=hx, oklch=[round(L, 3), round(c_used, 4), round(h_use, 1)], job=job)\n if paper_in:\n hx, c_used = oklch_hex(*paper_in)\n tokens['paper'] = dict(hex=hx, oklch=[round(paper_in[0], 3), round(c_used, 4), round(paper_in[2], 1)], job=tokens['paper']['job'] + ' (given)')\n if a.stage == 'dark' and not a.brand_supplied:\n sL, sC, sh = tokens['ink']['oklch']\n if 235 \u003c= sh \u003c= 270 and 0.004 \u003c= sC \u003c 0.02:\n print(f\"palette.py: note: the stage {tokens['ink']['hex']} (OKLCH {sL} {sC} {sh:.0f}) is the stock dev-tool \"\n 'dark theme (the GitHub / editor blue-grey). It reads as \"a screenshot of a tool\" before anything moves. '\n 'Take the stage from the brand\\'s world instead: --temp neutral for a true charcoal, or --stage-hue '\n '(pine-black 160, deep teal 200, an ink-navy with body: 235 with --stage-chroma 0.03). '\n 'references/brand-and-color.md §9.', file=sys.stderr)\n tokens['accent'] = dict(hex=acc_hex, oklch=[round(aL, 3), round(aC, 4), round(ah, 1)], job=f'MEANING: \"{a.meaning}\"')\n stage = tokens[stage_key]['hex']\n\n checks = []\n\n def check(name, ok, detail):\n checks.append(dict(check=name, pass_=bool(ok), detail=detail))\n\n r_text = contrast(tokens[text_key]['hex'], stage)\n r_mute = contrast(tokens['mute']['hex'], stage)\n r_acc = contrast(acc_hex, stage)\n check('statements', r_text >= 7, f'{text_key} on {stage_key} {r_text:.1f}:1 (>= 7)')\n check('secondary text', r_mute >= 4.5, f'mute on {stage_key} {r_mute:.1f}:1 (>= 4.5)')\n check('accent graphics', r_acc >= 3, f'accent on {stage_key} {r_acc:.1f}:1 (>= 3)')\n check('accent chroma', aC >= 0.08, f'C {aC:.3f} (>= 0.08, or it reads as a grey)')\n for k in ('ink', 'ink2', 'paper', 'mist', 'line', 'mute', 'accent'):\n h_ = parse_colour(tokens[k]['hex'])\n why = banned(*h_)\n if why and not a.brand_supplied:\n check(f'hard ban: {k}', False, f\"{tokens[k]['hex']} reads as {why}\")\n in_band = band[0] \u003c= aL \u003c= band[1]\n fix = ''\n if not in_band:\n L2 = min(max(aL, band[0]), band[1])\n fix = f'; try {oklch_hex(L2, aC, ah)[0]} (L {L2:.2f})'\n check('accent lightness', in_band, f'L {aL:.2f} for a {a.stage} stage ({band[0]}-{band[1]}){fix}')\n\n for k, v in tokens.items():\n r = contrast(v['hex'], stage) if k != stage_key else None\n v['contrast_on_stage'] = round(r, 2) if r else None\n width = max(len(k) for k in tokens)\n sh_arg = f' --stage-hue {a.stage_hue:g}' if a.stage_hue is not None else ''\n print(f'// palette.py --accent {a.accent} --stage {a.stage} --temp {a.temp}{sh_arg}')\n print('export const C = {')\n for k in ['ink', 'ink2', 'paper', 'mist', 'line', 'mute', 'accent']:\n v = tokens[k]\n L, C, h = v['oklch']\n r = f', {v[\"contrast_on_stage\"]:.1f}:1 on {stage_key}' if v['contrast_on_stage'] else ''\n print(f\" {k}: '{v['hex']}',{' ' * (width - len(k))} // {v['job']} (OKLCH {L} {C} {h:.0f}{r})\")\n print('};')\n for c in checks:\n print(f\" {'ok ' if c['pass_'] else 'FAIL'} {c['check']}: {c['detail']}\", file=sys.stderr)\n if a.json:\n json.dump(dict(stage=a.stage, temp=a.temp, tokens=tokens,\n checks=[dict(check=c['check'], ok=c['pass_'], detail=c['detail']) for c in checks]),\n open(a.json, 'w'), indent=1)\n return 0 if all(c['pass_'] for c in checks) else 1\n\n\nif __name__ == '__main__':\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":17885,"content_sha256":"92953451a472c91a41966b4c02bd61c2119c7ef2903658f1b61b6309301ced9f"},{"filename":"scripts/pick.py","content":"#!/usr/bin/env python3\n\"\"\"Draw techniques from the library at random, so films don't converge on the same few moves.\n\nAn agent left to choose picks the first familiar option: the same fade-up, the same push-in, the\nsame end card. pick.py draws from assets/library/techniques.json instead, weighted toward what\nfits the film (format, energy) and toward proven workhorses, and prints the build recipe for each\ndraw. The seed is printed, so a plan can be reproduced and written into TREATMENT.md.\n\nWeights: role (workhorse 3, accent 2, signature 1) x proof (1 + evidence / 4, capped at 3) x\nenergy fit (same 1.0, \"any\" 0.7, other 0.25). The energy defaults to calm, the house pace, and a\ncalm draw never takes a high-energy entry (weight 0: slams, whips, glitch streaks); pass --energy\nhigh for a brief that asks for energy, or --energy any to draw without the energy weight.\nEntries whose formats don't include the film's format (or \"any\") are never drawn. Entries that\nrely on a house-banned look (ban_safe false) are drawn with their ban-safe variant as the recipe,\nunless --brand-supplied names every look in the entry's \"bans\" list.\n\nUsage:\n python3 scripts/pick.py --format launch --seconds 25 # a calm whole-film plan (7 picks)\n python3 scripts/pick.py --format launch --energy high --seconds 25 # an energetic one (10 picks)\n python3 scripts/pick.py --format loop --seed 41 # reproducible plan (10 s: 4 picks)\n python3 scripts/pick.py --category transition --n 3 --format launch # three transitions\n python3 scripts/pick.py --category logo_and_end_card --exclude le-hard-cut-lockup\n python3 scripts/pick.py --plan --avoid out/qa/picks-previous.json # skip ids used before\n python3 scripts/pick.py --list [--category camera] # ids and names\n python3 scripts/pick.py --show tr-mask-wipe-swap # one entry in full\n python3 scripts/pick.py --validate # check the library file\n python3 scripts/pick.py --markdown > references/technique-library.md # regenerate the readable library\n python3 scripts/pick.py ... --json out/qa/picks.json # also write the draw as JSON\n\nA plan holds about one technique per 3.5 s of film when calm, per 2.5 s otherwise (--seconds),\nfilled in the format's order of need (a sting draws its end card and hook first, a walkthrough its\nproduct demo), with one signature move, or two in a plan of 10 or more. A slot whose category has\nnothing left at this energy passes to the next category in that order.\n\nRules for the agent (SKILL.md, Step 1): draw before you design; build the picks the concept can\ncarry, each retold in the film's objects; reroll a pick that fights the concept (--exclude it,\nsame --category) at most twice, or drop it, and write down why. Never add a technique the draw\ndid not give you: draw one with --category. A draw is a starting point, not a quota.\n\nExit 0 on success, 1 when nothing fits the filters, 2 on bad input or an invalid library.\n\"\"\"\nimport argparse\nimport json\nimport os\nimport random\nimport sys\n\nHERE = os.path.dirname(os.path.abspath(__file__))\n# The skill keeps the library in assets/library/; a film project made by new-film.sh carries its own\n# copy next to this script, in scripts/library/.\n_LIBRARIES = [os.path.normpath(os.path.join(HERE, '..', 'assets', 'library', 'techniques.json')),\n os.path.join(HERE, 'library', 'techniques.json')]\nDEFAULT_LIBRARY = next((p for p in _LIBRARIES if os.path.isfile(p)), _LIBRARIES[0])\n\nCATEGORIES = ['opening_hook', 'transition', 'camera', 'typography_motion', 'ui_choreography',\n 'data_and_numbers', 'product_demo', 'logo_and_end_card', 'color_and_light',\n 'texture_and_finish', 'layout_and_composition', 'pacing_structure',\n 'micro_interaction', 'depth_and_3d']\nFORMATS = ['launch', 'feature', 'loop', 'sting', 'walkthrough', 'vertical']\nENERGIES = ['calm', 'medium', 'high']\nROLE_WEIGHT = {'workhorse': 3.0, 'accent': 2.0, 'signature': 1.0}\nREQUIRED = ['id', 'name', 'category', 'summary', 'recipe', 'timing', 'use_when', 'avoid_when',\n 'formats', 'energy', 'role', 'ban_safe', 'evidence']\n\n# The order in which a plan fills its slots, by format: the first slots go to what that format\n# cannot do without. A film gets about one technique per 3.5 s when calm and per 2.5 s otherwise\n# (--seconds), so a 12 s vertical video draws 5 and a 30 s launch film 9 (calm) or 12: a short\n# film carries a few moves done well, and a plan stuffed with one move per category reads as a\n# showreel. A category may appear twice.\nORDER = {\n 'launch': ['opening_hook', 'transition', 'typography_motion', 'product_demo', 'ui_choreography',\n 'logo_and_end_card', 'camera', 'transition', 'data_and_numbers', 'micro_interaction',\n 'color_and_light', 'pacing_structure', 'typography_motion', 'depth_and_3d',\n 'layout_and_composition', 'texture_and_finish', 'transition', 'ui_choreography'],\n 'feature': ['product_demo', 'ui_choreography', 'opening_hook', 'transition', 'micro_interaction',\n 'typography_motion', 'logo_and_end_card', 'data_and_numbers', 'camera', 'color_and_light',\n 'ui_choreography', 'layout_and_composition'],\n 'loop': ['ui_choreography', 'product_demo', 'micro_interaction', 'typography_motion', 'transition',\n 'camera', 'pacing_structure', 'color_and_light'],\n 'sting': ['logo_and_end_card', 'opening_hook', 'typography_motion', 'micro_interaction', 'camera',\n 'texture_and_finish'],\n 'walkthrough': ['product_demo', 'ui_choreography', 'opening_hook', 'transition', 'micro_interaction',\n 'camera', 'typography_motion', 'logo_and_end_card', 'ui_choreography', 'product_demo',\n 'transition', 'pacing_structure', 'layout_and_composition', 'micro_interaction'],\n 'vertical': ['opening_hook', 'product_demo', 'ui_choreography', 'typography_motion', 'transition',\n 'logo_and_end_card', 'micro_interaction', 'data_and_numbers', 'camera', 'color_and_light',\n 'transition', 'typography_motion'],\n}\nDEFAULT_SECONDS = {'launch': 30, 'feature': 15, 'loop': 10, 'sting': 6, 'walkthrough': 45, 'vertical': 15}\nMIN_PICKS = {'launch': 6, 'feature': 5, 'loop': 4, 'sting': 4, 'walkthrough': 6, 'vertical': 5}\nSECONDS_PER_PICK = {'calm': 3.5} # a calm film carries fewer, longer moves\nDEFAULT_SECONDS_PER_PICK = 2.5\n\n\ndef plan_size(fmt, seconds, energy=None):\n \"\"\"How many techniques a film of this format, length and energy draws.\"\"\"\n n = int(round(seconds / SECONDS_PER_PICK.get(energy, DEFAULT_SECONDS_PER_PICK)))\n return max(MIN_PICKS[fmt], min(len(ORDER[fmt]), n))\n\n\ndef plan_slots(fmt, seconds, energy=None):\n \"\"\"{category: count} for the first plan_size() slots of the format's order.\"\"\"\n slots = {}\n for cat in ORDER[fmt][:plan_size(fmt, seconds, energy)]:\n slots[cat] = slots.get(cat, 0) + 1\n return slots\n\n\nBANS = ['glass', 'glow', 'serif', 'italic', 'orange', 'beige', 'purple', 'gradient', 'filler-text',\n 'emoji', 'sparkle', 'confetti', 'particles']\n\n\ndef fail(msg, code=2):\n print(f'pick.py: {msg}', file=sys.stderr)\n sys.exit(code)\n\n\ndef load(path):\n try:\n with open(path, encoding='utf-8') as fh:\n data = json.load(fh)\n except (OSError, ValueError) as e:\n fail(f'cannot read the library {path}: {e}')\n entries = data['techniques'] if isinstance(data, dict) else data\n problems = validate(entries)\n if problems:\n fail('invalid library:\\n ' + '\\n '.join(problems[:20]))\n return entries\n\n\ndef validate(entries):\n problems, seen = [], set()\n if not isinstance(entries, list) or not entries:\n return ['the library holds no techniques']\n for i, e in enumerate(entries):\n where = e.get('id', f'#{i}') if isinstance(e, dict) else f'#{i}'\n if not isinstance(e, dict):\n problems.append(f'{where}: not an object')\n continue\n for k in REQUIRED:\n if k not in e or e[k] in ('', None):\n problems.append(f'{where}: missing {k}')\n if e.get('id') in seen:\n problems.append(f'{where}: duplicate id')\n seen.add(e.get('id'))\n if e.get('category') not in CATEGORIES:\n problems.append(f'{where}: unknown category {e.get(\"category\")!r}')\n fm = e.get('formats') or []\n if not isinstance(fm, list) or not fm or any(f not in FORMATS + ['any'] for f in fm):\n problems.append(f'{where}: formats must be a non-empty list of {FORMATS + [\"any\"]}')\n if e.get('energy') not in ENERGIES + ['any']:\n problems.append(f'{where}: energy must be one of {ENERGIES + [\"any\"]}')\n if e.get('role') not in ROLE_WEIGHT:\n problems.append(f'{where}: role must be one of {list(ROLE_WEIGHT)}')\n if e.get('ban_safe') is False and not e.get('ban_safe_variant'):\n problems.append(f'{where}: ban_safe is false but there is no ban_safe_variant')\n if not isinstance(e.get('evidence', 0), (int, float)) or isinstance(e.get('evidence'), bool):\n problems.append(f'{where}: evidence must be a number')\n bans = e.get('bans', [])\n if not isinstance(bans, list) or any(b not in BANS for b in bans):\n problems.append(f'{where}: bans must be a list drawn from {BANS}')\n elif e.get('ban_safe') is True and bans:\n problems.append(f'{where}: ban_safe is true but bans lists {bans}')\n elif e.get('ban_safe') is False and not bans:\n problems.append(f'{where}: ban_safe is false, so bans must name the banned looks it relies on')\n return problems\n\n\ndef weight(e, energy):\n w = ROLE_WEIGHT[e['role']] * min(3.0, 1.0 + float(e.get('evidence') or 0) / 4.0)\n if energy == 'calm' and e['energy'] == 'high':\n return 0.0 # a calm film never draws a slam, a whip or a glitch streak\n if energy and energy != 'any':\n w *= 1.0 if e['energy'] == energy else (0.7 if e['energy'] == 'any' else 0.25)\n return w\n\n\ndef fits(e, fmt):\n return not fmt or 'any' in e['formats'] or fmt in e['formats']\n\n\ndef draw(pool, n, rng, energy, taken, signature_left):\n \"\"\"Weighted draw without replacement. Returns (picks, signature_left).\"\"\"\n picks = []\n cand = [e for e in pool if e['id'] not in taken and weight(e, energy) > 0]\n while len(picks) \u003c n and cand:\n if signature_left \u003c= 0:\n cand = [e for e in cand if e['role'] != 'signature']\n if not cand:\n break\n ws = [weight(e, energy) for e in cand]\n e = rng.choices(cand, weights=ws, k=1)[0]\n picks.append(e)\n taken.add(e['id'])\n if e['role'] == 'signature':\n signature_left -= 1\n cand = [c for c in cand if c['id'] != e['id']]\n return picks, signature_left\n\n\ndef brand_allows(e, allowed):\n \"\"\"True when the brand supplies every banned look this entry's original recipe relies on.\"\"\"\n if not allowed:\n return False\n if e.get('bans'):\n return set(e['bans']) \u003c= allowed\n note = (e.get('ban_note', '') + ' ' + e.get('recipe', '')).lower()\n return any(b in note for b in allowed)\n\n\ndef render(e, allowed):\n lines = [f\"### {e['name']} `{e['id']}`\",\n f\"*{e['category']} · {e['role']} · energy {e['energy']} · formats {', '.join(e['formats'])}*\", '',\n e['summary'], '']\n if e.get('ban_safe') is False and not brand_allows(e, allowed):\n lines += [f\"**Build (ban-safe variant):** {e['ban_safe_variant']}\",\n f\"(The full look touches a house ban; use it only for a brand that supplies it: {e['recipe']})\"]\n else:\n lines += [f\"**Build:** {e['recipe']}\"]\n lines += [f\"**Timing:** {e['timing']}\", f\"**Use when:** {e['use_when']}\", f\"**Avoid when:** {e['avoid_when']}\", '']\n return '\\n'.join(lines)\n\n\nCATEGORY_TITLES = {\n 'opening_hook': 'Opening hooks', 'transition': 'Transitions', 'camera': 'Camera',\n 'typography_motion': 'Typography in motion', 'ui_choreography': 'UI choreography',\n 'data_and_numbers': 'Data and numbers', 'product_demo': 'Product demo', 'logo_and_end_card': 'Logo and end card',\n 'color_and_light': 'Colour and light', 'texture_and_finish': 'Texture and finish',\n 'layout_and_composition': 'Layout and composition', 'pacing_structure': 'Pacing and structure',\n 'micro_interaction': 'Micro-interactions', 'depth_and_3d': 'Depth and 3D',\n}\n\n\ndef library_markdown(entries, path):\n \"\"\"The readable library: generated from the JSON so the two never drift (CI checks it).\"\"\"\n try:\n with open(path, encoding='utf-8') as fh:\n data = json.load(fh)\n intros = data.get('intros', {}) if isinstance(data, dict) else {}\n except (OSError, ValueError):\n intros = {}\n by = {c: [e for e in entries if e['category'] == c] for c in CATEGORIES}\n out = ['# Technique library', '',\n '\u003c!-- Generated by `python3 scripts/pick.py --markdown` from assets/library/techniques.json. Edit the JSON, then regenerate. -->', '',\n f'{len(entries)} techniques in {sum(1 for v in by.values() if v)} categories, each with a build recipe, default timing at 60 fps, '\n 'and when to use it. Don\\'t choose from this list by hand: draw with `scripts/pick.py` (SKILL.md, Step 1), then come here '\n 'to read an entry in full or to find an alternative after a reroll. Roles: a **workhorse** carries a film and can recur, '\n 'an **accent** spices one moment, a **signature** is a hero move used once (at most 2 per launch film).', '',\n '## Contents', '']\n for c in CATEGORIES:\n if by[c]:\n title = CATEGORY_TITLES[c]\n out.append(f\"- [{title}](#{title.lower().replace(' ', '-')}) ({len(by[c])})\")\n out.append('')\n for c in CATEGORIES:\n if not by[c]:\n continue\n out += [f'## {CATEGORY_TITLES[c]}', '']\n if intros.get(c):\n out += [intros[c], '']\n for e in sorted(by[c], key=lambda e: (list(ROLE_WEIGHT).index(e['role']), e['name'].lower())):\n out += [f\"### {e['name']}\", '',\n f\"`{e['id']}` · {e['role']} · energy {e['energy']} · {', '.join(e['formats'])}\", '',\n e['summary'], '', f\"- **Build:** {e['recipe']}\"]\n if e.get('ban_safe') is False:\n out.append(f\"- **Ban-safe variant (the default):** {e['ban_safe_variant']} The full look relies on \"\n f\"{', '.join(e.get('bans', []))}; use it only when the brand supplies that look.\")\n out += [f\"- **Timing:** {e['timing']}\", f\"- **Use when:** {e['use_when']}\", f\"- **Avoid when:** {e['avoid_when']}\", '']\n return '\\n'.join(out).rstrip() + '\\n'\n\n\ndef main():\n ap = argparse.ArgumentParser(description=__doc__.split('\\n\\n')[0], formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=__doc__.split('\\n\\n', 1)[1])\n ap.add_argument('--library', default=DEFAULT_LIBRARY, help='techniques JSON (default: assets/library/techniques.json in the skill, scripts/library/techniques.json in a film project)')\n ap.add_argument('--format', choices=FORMATS, help='the film format; filters entries and sets the plan shape')\n ap.add_argument('--energy', choices=ENERGIES + ['any'], default='calm',\n help='weights entries toward this energy (default calm: no high-energy entries; any: no energy weight)')\n ap.add_argument('--seconds', type=float, help='the film length: about one technique per 3.5 s calm, 2.5 s otherwise (default per format)')\n ap.add_argument('--category', choices=CATEGORIES, help='draw only from this category')\n ap.add_argument('--n', type=int, default=1, help='how many to draw with --category (default 1)')\n ap.add_argument('--plan', action='store_true', help='draw a whole-film plan (the default without --category)')\n ap.add_argument('--seed', type=int, help='seed for a reproducible draw (default: random, printed)')\n ap.add_argument('--exclude', default='', help='comma-separated ids never to draw (e.g. a reroll)')\n ap.add_argument('--avoid', help='a JSON from an earlier --json run; its ids are not drawn again')\n ap.add_argument('--brand-supplied', default='', help=f'comma-separated banned looks the brand supplies: {\",\".join(BANS)}')\n ap.add_argument('--json', help='also write the draw here')\n ap.add_argument('--list', action='store_true', help='list ids and names (optionally one --category)')\n ap.add_argument('--show', metavar='ID', help='print one entry in full')\n ap.add_argument('--validate', action='store_true', help='check the library file and exit')\n ap.add_argument('--markdown', action='store_true', help='print the whole library as Markdown (references/technique-library.md)')\n a = ap.parse_args()\n\n entries = load(a.library)\n if a.validate:\n by = {c: sum(1 for e in entries if e['category'] == c) for c in CATEGORIES}\n print(f'pick.py: {len(entries)} techniques in {sum(1 for v in by.values() if v)} categories: '\n + ', '.join(f'{c} {n}' for c, n in by.items()))\n empty = [c for c, n in by.items() if n == 0]\n if empty:\n fail(f'categories with no entries: {\", \".join(empty)}')\n return 0\n if a.markdown:\n print(library_markdown(entries, a.library))\n return 0\n if a.list:\n for e in entries:\n if not a.category or e['category'] == a.category:\n print(f\"{e['id']:\u003c34} {e['category']:\u003c22} {e['role']:\u003c9} {e['name']}\")\n return 0\n if a.show:\n hit = [e for e in entries if e['id'] == a.show]\n if not hit:\n fail(f'no technique {a.show!r} (run --list)', 1)\n print(render(hit[0], set()))\n return 0\n if a.n \u003c 1:\n fail('--n must be at least 1')\n\n seed = a.seed if a.seed is not None else int.from_bytes(os.urandom(4), 'big')\n rng = random.Random(seed)\n allowed = {b.strip().lower() for b in a.brand_supplied.split(',') if b.strip()}\n unknown = allowed - set(BANS)\n if unknown:\n fail(f'--brand-supplied: unknown look(s) {\", \".join(sorted(unknown))}; choose from {\", \".join(BANS)}')\n taken = {x.strip() for x in a.exclude.split(',') if x.strip()}\n if a.avoid:\n try:\n prev = json.load(open(a.avoid, encoding='utf-8'))\n taken |= {p['id'] for p in prev.get('picks', [])}\n except (OSError, ValueError, KeyError, TypeError) as e:\n fail(f'--avoid {a.avoid}: {e}')\n pool = [e for e in entries if fits(e, a.format)]\n\n picks = []\n if a.category and not a.plan:\n cat_pool = [e for e in pool if e['category'] == a.category]\n picks, _ = draw(cat_pool, a.n, rng, a.energy, taken, signature_left=a.n)\n else:\n fmt = a.format or 'launch'\n seconds = a.seconds if a.seconds else DEFAULT_SECONDS[fmt]\n size = plan_size(fmt, seconds, a.energy)\n sig = 2 if size >= 10 else 1 # signature moves per film\n for cat, n in plan_slots(fmt, seconds, a.energy).items():\n got, sig = draw([e for e in pool if e['category'] == cat], n, rng, a.energy, taken, sig)\n picks += got\n # A slot whose category has nothing at this energy passes on down the format's order.\n rest = ORDER[fmt][size:] + ORDER[fmt]\n while len(picks) \u003c size and rest:\n cat = rest.pop(0)\n got, sig = draw([e for e in pool if e['category'] == cat], 1, rng, a.energy, taken, sig)\n picks += got\n if not picks:\n if a.category and not a.plan:\n left = [e['id'] for e in pool if e['category'] == a.category and e['id'] not in taken]\n fail(f'no {a.category} technique is left for --format {a.format or \"any\"} '\n f'({len(taken)} excluded, {len(left)} left). That pick has run out: drop it, with a reason in the '\n 'treatment, or take the slot from another category', 1)\n fail('nothing fits these filters (try a broader --format)', 1)\n\n head = (f\"\u003c!-- pick.py --seed {seed}\"\n + (f\" --format {a.format}\" if a.format else '') + (f\" --energy {a.energy}\" if a.energy else '')\n + (f\" --seconds {a.seconds:g}\" if a.seconds and not (a.category and not a.plan) else '')\n + (f\" --category {a.category} --n {a.n}\" if a.category and not a.plan else '') + ' -->')\n print(head)\n print(f\"# Drawn techniques (seed {seed})\\n\")\n print('Build the picks the concept can carry, each retold in its objects. Reroll a pick at most twice, or drop it, with a written reason.\\n')\n if len(picks) > 1:\n for e in picks:\n print(f\"- {e['category']}: {e['name']} (`{e['id']}`, {e['role']})\")\n print()\n for e in picks:\n print(render(e, allowed))\n if a.json:\n os.makedirs(os.path.dirname(os.path.abspath(a.json)), exist_ok=True)\n with open(a.json, 'w', encoding='utf-8') as fh:\n json.dump({'seed': seed, 'format': a.format, 'energy': a.energy, 'category': a.category,\n 'picks': [{'id': e['id'], 'name': e['name'], 'category': e['category'], 'role': e['role']}\n for e in picks]}, fh, indent=1)\n return 0\n\n\nif __name__ == '__main__':\n try:\n sys.exit(main())\n except BrokenPipeError: # `pick.py | head`: the reader closed early\n os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())\n sys.exit(0)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":21578,"content_sha256":"f54b9ed3469b48ab896987e2730a7554a5e75423c02b4d3e944cadbb212e430e"},{"filename":"scripts/probe.py","content":"#!/usr/bin/env python3\n\"\"\"Pass/fail spec check on a rendered file: size, fps, duration, codec, pixel format, BT.709 tags, audio.\n\nRun it on every master, preview and deliverable. Players assume BT.709 for HD, so an untagged or\nBT.601 file shifts colour on playback; a wrong frame count or a missing/odd audio stream is a\nbroken deliverable even if the picture looks right.\n\nUsage:\n python3 scripts/probe.py out/film.mp4 --spec 1920x1080@60 --dur 30\n python3 scripts/probe.py out/film.mp4 --spec 1920x1080@60 --frames 1800 --lufs -14 --tp-max -1\n python3 scripts/probe.py out/deliver/loop.mp4 --spec 1920x1080@60 --audio none\n python3 scripts/probe.py out/deliver/Sting.mov --pix-fmt yuva444p10le,yuva444p12le --vcodec prores --audio none\n python3 scripts/probe.py out/deliver/loop.gif --spec 960x540@30 --audio none --max-mb 8\n\nChecks (each only when it applies): size, fps, duration (+-tol, default 1 frame), frame count,\nvideo codec (default h264 for .mp4), pix_fmt (default yuv420p for h264), BT.709 tags\n(colorspace/primaries/transfer = bt709, range tv; skipped for GIF), audio presence\n(--audio required|none|any), audio codec (aac for .mp4/.mov), sample rate 48 kHz, stereo,\naudio length vs video length, optional loudness gates (--lufs, --tp-max) and file size (--max-mb).\nOptional extras: --motion (frozen-frame/stutter stats) and --sheet (6x4 contact sheet).\n\nPrints a JSON report on stdout (also to --json FILE), a readable summary on stderr.\nExit code 0 = every check passed, 1 = at least one failed or the file is unreadable.\n\"\"\"\nimport argparse\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nfrom fractions import Fraction\n\n\ndef parse_args():\n ap = argparse.ArgumentParser(description=__doc__.split('\\n\\n')[0], formatter_class=argparse.RawDescriptionHelpFormatter)\n ap.add_argument('video')\n ap.add_argument('--spec', help='WxH@fps, WxH or @fps, e.g. 1920x1080@60')\n ap.add_argument('--dur', type=float, help='expected duration in seconds')\n ap.add_argument('--frames', type=int, help='expected frame count (overrides --dur)')\n ap.add_argument('--tol', type=float, help='duration tolerance in seconds (default: 1 frame)')\n ap.add_argument('--vcodec', help='expected video codec (default h264 for .mp4, none otherwise)')\n ap.add_argument('--pix-fmt', help='expected pix_fmt (default yuv420p when the codec is h264)')\n ap.add_argument('--no-color-check', action='store_true', help='skip the BT.709 tag check')\n ap.add_argument('--audio', choices=['required', 'none', 'any'], default='required',\n help='audio stream expectation (default required; use none for silent loops)')\n ap.add_argument('--acodec', help='expected audio codec (default aac for .mp4/.mov/.m4v)')\n ap.add_argument('--sample-rate', type=int, default=48000)\n ap.add_argument('--channels', type=int, default=2)\n ap.add_argument('--lufs', type=float, help='gate integrated loudness at this target (+-lufs-tol)')\n ap.add_argument('--lufs-tol', type=float, default=1.0)\n ap.add_argument('--tp-max', type=float, help='gate true peak (dBTP), e.g. -1')\n ap.add_argument('--max-mb', type=float, help='file size budget in MB')\n ap.add_argument('--no-loudness', action='store_true', help='skip the ebur128 measurement')\n ap.add_argument('--motion', action='store_true', help='also report frozen/stutter frames (decodes the video)')\n ap.add_argument('--sheet', help='write a 6x4 contact sheet PNG here')\n ap.add_argument('--json', help='also write the JSON report to this file')\n ap.add_argument('--quiet', action='store_true', help='no summary on stderr')\n return ap.parse_args()\n\n\ndef parse_spec(spec):\n w = h = fps = None\n if spec:\n m = re.fullmatch(r'\\s*(?:(\\d+)x(\\d+))?\\s*(?:@\\s*([\\d.]+(?:/[\\d.]+)?))?\\s*', spec)\n if not m or not any(m.groups()):\n sys.exit(f'probe.py: cannot parse --spec {spec!r} (want WxH@fps, e.g. 1920x1080@60)')\n if m.group(1):\n w, h = int(m.group(1)), int(m.group(2))\n if m.group(3):\n fps = float(Fraction(m.group(3)))\n return w, h, fps\n\n\ndef rate(s):\n try:\n f = Fraction(s)\n return float(f) if f > 0 else None\n except (ValueError, ZeroDivisionError, TypeError):\n return None\n\n\ndef ffprobe(path):\n r = subprocess.run(['ffprobe', '-v', 'error', '-count_packets', '-show_streams', '-show_format', '-of', 'json', path],\n capture_output=True, text=True)\n if r.returncode:\n return None, r.stderr.strip()\n return json.loads(r.stdout), None\n\n\ndef loudness(path):\n r = subprocess.run(['ffmpeg', '-nostats', '-hide_banner', '-i', path, '-map', '0:a:0', '-af', 'ebur128=peak=true',\n '-f', 'null', '-'], capture_output=True, text=True)\n s = r.stderr[r.stderr.rfind('Summary:'):]\n\n def num(pat):\n m = re.search(pat, s)\n if not m or 'inf' in m.group(1):\n return None\n return float(m.group(1))\n return {'lufs': num(r'I:\\s+(-?[\\d.]+|-inf) LUFS'), 'lra': num(r'LRA:\\s+(-?[\\d.]+) LU'),\n 'true_peak_dbtp': num(r'Peak:\\s+(-?[\\d.]+|-inf) dBFS')}\n\n\ndef motion_stats(path):\n import numpy as np\n W, H = 480, 270\n dec = subprocess.Popen(['ffmpeg', '-v', 'error', '-i', path, '-vf', f'scale={W}:{H}:flags=area', '-f', 'rawvideo',\n '-pix_fmt', 'gray', '-'], stdout=subprocess.PIPE)\n prev, diffs = None, []\n while True:\n b = dec.stdout.read(W * H)\n if len(b) \u003c W * H:\n break\n f = np.frombuffer(b, np.uint8).astype(np.int16)\n if prev is not None:\n diffs.append(float(np.abs(f - prev).mean()))\n prev = f\n dec.wait()\n d = np.array(diffs)\n if len(d) \u003c 3:\n return {'steps': len(d)}\n moving = d > 0.5\n stutter = np.where(~moving[1:-1] & moving[:-2] & moving[2:])[0] + 2 # frame index of the repeated frame\n return {'steps': len(d), 'moving_ratio': round(float(moving.mean()), 3), 'median_step': round(float(np.median(d)), 3),\n 'max_step': round(float(d.max()), 2), 'hard_cuts': int(np.sum(d > 25)),\n 'stutter_frames': [int(x) for x in stutter[:50]], 'stutter_count': int(len(stutter))}\n\n\ndef sheet(path, out, dur, fps):\n n = 24\n idx = [int(dur * (i + 0.5) / n * fps) for i in range(n)]\n sel = '+'.join(f'eq(n\\\\,{i})' for i in idx)\n r = subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', path, '-vf',\n f\"select='{sel}',scale=480:-2:in_color_matrix=auto:in_range=auto,tile=6x4:padding=6:color=0x808080\",\n '-frames:v', '1', out])\n return r.returncode == 0\n\n\ndef main():\n a = parse_args()\n ext = os.path.splitext(a.video)[1].lower()\n report = {'file': a.video, 'pass': False, 'checks': [], 'facts': {}}\n checks = report['checks']\n\n def check(name, ok, expected, actual, note=None):\n c = {'name': name, 'ok': bool(ok), 'expected': expected, 'actual': actual}\n if note:\n c['note'] = note\n checks.append(c)\n\n def finish():\n report['pass'] = bool(checks) and all(c['ok'] for c in checks)\n out = json.dumps(report, indent=1)\n print(out)\n if a.json:\n os.makedirs(os.path.dirname(os.path.abspath(a.json)), exist_ok=True)\n with open(a.json, 'w') as fh:\n fh.write(out + '\\n')\n if not a.quiet:\n for c in checks:\n mark = 'ok ' if c['ok'] else 'FAIL'\n sys.stderr.write(f\" {mark} {c['name']:\u003c12} expected {c['expected']}, got {c['actual']}\"\n + (f\" ({c['note']})\" if c.get('note') else '') + '\\n')\n sys.stderr.write(('probe PASS' if report['pass'] else 'probe FAIL') + f': {a.video}\\n')\n sys.exit(0 if report['pass'] else 1)\n\n w, h, want_fps = parse_spec(a.spec)\n if not os.path.exists(a.video):\n report['error'] = 'file not found'\n check('exists', False, 'file', 'missing')\n finish()\n info, err = ffprobe(a.video)\n if info is None:\n report['error'] = err\n check('readable', False, 'decodable', err)\n finish()\n\n streams = info.get('streams', [])\n v = next((s for s in streams if s['codec_type'] == 'video' and not s.get('disposition', {}).get('attached_pic')), None)\n au = next((s for s in streams if s['codec_type'] == 'audio'), None)\n if v is None:\n check('video', False, 'a video stream', 'none')\n finish()\n\n fps = rate(v.get('avg_frame_rate')) or rate(v.get('r_frame_rate'))\n frames = int(v.get('nb_read_packets') or v.get('nb_frames') or 0)\n fmt_dur = float(info['format'].get('duration') or 0)\n v_dur = frames / fps if fps and frames else float(v.get('duration') or fmt_dur)\n size_mb = os.path.getsize(a.video) / 1e6\n facts = report['facts']\n facts.update(width=v['width'], height=v['height'], fps=round(fps, 4) if fps else None, frames=frames,\n duration=round(v_dur, 4), container_duration=round(fmt_dur, 4), vcodec=v['codec_name'],\n profile=v.get('profile'), pix_fmt=v.get('pix_fmt'),\n color={'space': v.get('color_space'), 'primaries': v.get('color_primaries'),\n 'transfer': v.get('color_transfer'), 'range': v.get('color_range')},\n size_mb=round(size_mb, 3), bitrate_kbps=round(size_mb * 8000 / fmt_dur) if fmt_dur else None)\n\n if w:\n check('size', (v['width'], v['height']) == (w, h), f'{w}x{h}', f\"{v['width']}x{v['height']}\")\n if want_fps:\n check('fps', fps is not None and abs(fps - want_fps) \u003c 0.01, want_fps, facts['fps'])\n ref_fps = want_fps or fps or 60\n tol = a.tol if a.tol is not None else 1.0 / ref_fps + 1e-3\n if a.frames is not None:\n check('frames', abs(frames - a.frames) \u003c= max(0, round(tol * ref_fps - 1e-3)), a.frames, frames)\n check('duration', abs(v_dur - a.frames / ref_fps) \u003c= tol, f'{a.frames / ref_fps:.3f}s +-{tol:.3f}', f'{v_dur:.3f}s')\n elif a.dur is not None:\n check('duration', abs(v_dur - a.dur) \u003c= tol, f'{a.dur:.3f}s +-{tol:.3f}', f'{v_dur:.3f}s')\n\n vcodec = a.vcodec or ('h264' if ext in ('.mp4', '.m4v') else None)\n if vcodec:\n check('vcodec', v['codec_name'] == vcodec, vcodec, v['codec_name'])\n pix = a.pix_fmt or ('yuv420p' if v['codec_name'] == 'h264' else None)\n if pix: # comma-separated alternatives (ffprobe reports ProRes 4444 as yuva444p12le)\n check('pix_fmt', v.get('pix_fmt') in pix.split(','), pix, v.get('pix_fmt'))\n if not a.no_color_check and v['codec_name'] not in ('gif', 'png', 'apng', 'mjpeg'):\n col = facts['color']\n # ProRes carries primaries/transfer but often no matrix tag; its decoder assumes bt709 for HD\n space_ok = col['space'] == 'bt709' or (v['codec_name'] == 'prores' and col['space'] in (None, 'unknown'))\n range_ok = col['range'] == 'tv' or (v['codec_name'] == 'prores' and col['range'] in (None, 'unknown'))\n ok = space_ok and range_ok and col['primaries'] == 'bt709' and col['transfer'] == 'bt709'\n check('bt709', ok, 'bt709/bt709/bt709/tv',\n f\"{col['space']}/{col['primaries']}/{col['transfer']}/{col['range']}\",\n None if ok else 'encode with scale=out_color_matrix=bt709:out_range=tv and -colorspace/-color_primaries/'\n '-color_trc bt709 -color_range tv, and end -vf with setparams=color_primaries=bt709:'\n 'color_trc=bt709:colorspace=bt709:range=tv (ffmpeg 7.1+ takes the tags from the frames; '\n 'see references/finishing.md)')\n\n if au is not None:\n a_frames = au.get('duration')\n a_dur = float(a_frames) if a_frames else fmt_dur\n facts['audio'] = {'codec': au['codec_name'], 'sample_rate': int(au.get('sample_rate', 0)),\n 'channels': au.get('channels'), 'duration': round(a_dur, 4),\n 'bitrate_kbps': round(int(au['bit_rate']) / 1000) if au.get('bit_rate') else None}\n if a.audio == 'required':\n check('audio', au is not None, 'present', 'present' if au else 'none')\n elif a.audio == 'none':\n check('audio', au is None, 'none', au['codec_name'] if au else 'none',\n None if au is None else 'mux with -an for silent deliverables')\n if au is not None and a.audio != 'none':\n acodec = a.acodec or ('aac' if ext in ('.mp4', '.mov', '.m4v') else None)\n if acodec:\n check('acodec', au['codec_name'] == acodec, acodec, au['codec_name'])\n check('sample_rate', int(au.get('sample_rate', 0)) == a.sample_rate, a.sample_rate, int(au.get('sample_rate', 0)))\n check('channels', au.get('channels') == a.channels, a.channels, au.get('channels'))\n a_tol = 1.5 / ref_fps + 2048 / max(1, a.sample_rate) + 0.005\n a_dur = facts['audio']['duration']\n check('audio_len', abs(a_dur - v_dur) \u003c= a_tol, f'{v_dur:.3f}s +-{a_tol:.3f}', f'{a_dur:.3f}s',\n None if abs(a_dur - v_dur) \u003c= a_tol else 'mux with -af apad -shortest (or trim the WAV to the film)')\n if not a.no_loudness or a.lufs is not None or a.tp_max is not None:\n L = loudness(a.video)\n report['loudness'] = L\n if a.lufs is not None:\n ok = L['lufs'] is not None and abs(L['lufs'] - a.lufs) \u003c= a.lufs_tol\n check('lufs', ok, f'{a.lufs}+-{a.lufs_tol}', L['lufs'])\n if a.tp_max is not None:\n ok = L['true_peak_dbtp'] is not None and L['true_peak_dbtp'] \u003c= a.tp_max\n check('true_peak', ok, f'\u003c= {a.tp_max} dBTP', L['true_peak_dbtp'])\n\n if a.max_mb is not None:\n check('file_size', size_mb \u003c= a.max_mb, f'\u003c= {a.max_mb} MB', f'{size_mb:.2f} MB')\n if a.motion:\n report['motion'] = motion_stats(a.video)\n if a.sheet and fps:\n report['sheet'] = a.sheet if sheet(a.video, a.sheet, v_dur, fps) else None\n finish()\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":14004,"content_sha256":"a42cb29053ee402b58eff0491779161b77862c4dbd05e36dafcab2a923966dc9"},{"filename":"scripts/render-chunks.sh","content":"#!/usr/bin/env bash\n# render-chunks.sh: parallel chunked render. Bundle once, render N frame ranges as N independent\n# Chromium processes (concurrency 1 each, each with its own GPU process), join them losslessly\n# with ffmpeg concat (-c copy), mux the soundtrack, then run the same sync + spec gates as render.sh.\n# On machines where one browser with many tabs stalls (GPU/compositor bound scenes, WebGL), this\n# is often faster than a single render at the same total concurrency. Sharp renders only; for the\n# motion-blurred master use render.sh --blur.\n#\n# Usage:\n# bash scripts/render-chunks.sh \u003cComp> \u003cout.mp4> [chunks] [crf] [options] [-- extra remotion args]\n#\n# bash scripts/render-chunks.sh Film out/film.mp4 # chunks = half the CPUs, CRF 14\n# bash scripts/render-chunks.sh Film out/film.mp4 6 16 -- --gl=angle\n# bash scripts/render-chunks.sh Act2 out/act2.mp4 2 --audio-offset 360\n#\n# Options:\n# --audio FILE soundtrack (default public/audio/soundtrack.wav)\n# --no-audio no mux, no sync check\n# --audio-offset F film frame at which this composition starts (for per-act compositions)\n# --entry FILE Remotion entry (default src/index.ts)\n# --preset P x264 preset (default slow)\n# --no-check skip check-sync.py and probe.py\n# --keep keep the work directory\n# Exit 0 = rendered and gates passed, 1 = a chunk, the join or a gate failed.\nset -euo pipefail\n\nSCRIPT_DIR=$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\nusage() { sed -n '2,/^set -euo/p' \"${BASH_SOURCE[0]}\" | sed '$d' | sed 's/^# \\{0,1\\}//'; exit \"${1:-0}\"; }\ndie() { echo \"render-chunks.sh: $*\" >&2; exit 1; }\nT0=$(date +%s)\nstep() { echo \"[chunks $(( $(date +%s) - T0 ))s] $*\" >&2; }\n\nAUDIO=public/audio/soundtrack.wav; NOAUDIO=0; AOFF=0; ENTRY=src/index.ts; PRESET=slow; NOCHECK=0; KEEP=0\nPOS=(); EXTRA=()\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n -h|--help) usage 0 ;;\n --audio) AUDIO=${2:?}; shift ;;\n --no-audio) NOAUDIO=1 ;;\n --audio-offset) AOFF=${2:?}; shift ;;\n --entry) ENTRY=${2:?}; shift ;;\n --preset) PRESET=${2:?}; shift ;;\n --no-check) NOCHECK=1 ;;\n --keep) KEEP=1 ;;\n --) shift; EXTRA=(\"$@\"); break ;;\n -*) die \"unknown option $1 (see --help)\" ;;\n *) POS+=(\"$1\") ;;\n esac\n shift\ndone\n[[ ${#POS[@]} -ge 2 && ${#POS[@]} -le 4 ]] || usage 1\nCOMP=${POS[0]}; OUT=${POS[1]}\nNPROC=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) # sysctl: macOS has no nproc\nCHUNKS=${POS[2]:-$(( NPROC / 2 > 2 ? NPROC / 2 : 2 ))}\nCRF=${POS[3]:-14}\n[[ \"$CHUNKS\" =~ ^[0-9]+$ && \"$CHUNKS\" -ge 1 ]] || die \"chunks must be a positive integer\"\n\nif [[ ! -f \"$ENTRY\" ]]; then\n if [[ -f \"$SCRIPT_DIR/../$ENTRY\" ]]; then cd \"$SCRIPT_DIR/..\"; else die \"no $ENTRY here; run from the project root or pass --entry\"; fi\nfi\n[[ $NOAUDIO -eq 1 || -f \"$AUDIO\" ]] || die \"soundtrack $AUDIO not found (or pass --no-audio)\"\nmkdir -p \"$(dirname \"$OUT\")\" out/qa\nOUT_ABS=$(cd \"$(dirname \"$OUT\")\" && pwd)/$(basename \"$OUT\")\nNAME=$(basename \"${OUT%.*}\")\n\nWORK=$(mktemp -d \"${TMPDIR:-/tmp}/cinetic-chunks.XXXXXX\")\nif [[ $KEEP -eq 1 ]]; then echo \"render-chunks.sh: keeping $WORK\" >&2; else trap 'rm -rf \"$WORK\"' EXIT; fi\n\nstep \"bundling $ENTRY\"\nnpx remotion bundle \"$ENTRY\" --out-dir \"$WORK/bundle\" --log=error >/dev/null || die \"bundle failed\"\nLIST=$(npx remotion compositions \"$WORK/bundle\" 2>/dev/null) || die \"could not list compositions\"\nread -r FPS SIZE TOTAL \u003c\u003c\u003c \"$(awk -v id=\"$COMP\" '$1 == id && $2 ~ /^[0-9.]+$/ && $3 ~ /^[0-9]+x[0-9]+$/ { print $2, $3, $4; exit }' \u003c\u003c\u003c \"$LIST\")\" || true\n[[ -n \"${FPS:-}\" ]] || die \"no video composition '$COMP'\"\n(( CHUNKS \u003c= TOTAL )) || CHUNKS=$TOTAL\nper=$(( (TOTAL + CHUNKS - 1) / CHUNKS ))\n\nstep \"$COMP: ${SIZE}@${FPS}, $TOTAL frames in $CHUNKS chunks of \u003c= $per\"\npids=()\nfor ((i = 0; i \u003c CHUNKS; i++)); do\n a=$(( i * per )); b=$(( (i + 1) * per - 1 )); (( b >= TOTAL )) && b=$(( TOTAL - 1 ))\n (( a > b )) && break\n npx remotion render \"$WORK/bundle\" \"$COMP\" \"$WORK/part$i.mp4\" --frames=\"$a-$b\" --concurrency=1 \\\n --crf=\"$CRF\" --x264-preset=\"$PRESET\" --pixel-format=yuv420p --color-space=bt709 --muted --log=error \\\n ${EXTRA[@]+\"${EXTRA[@]}\"} > \"$WORK/part$i.log\" 2>&1 &\n pids+=($!)\ndone\nfail=0\nfor i in \"${!pids[@]}\"; do\n if ! wait \"${pids[$i]}\"; then echo \"render-chunks.sh: chunk $i failed:\" >&2; tail -20 \"$WORK/part$i.log\" >&2; fail=1; fi\ndone\n[[ $fail -eq 0 ]] || die \"a chunk failed\"\n\nstep \"joining ${#pids[@]} chunks\"\nfor ((i = 0; i \u003c ${#pids[@]}; i++)); do echo \"file '$WORK/part$i.mp4'\"; done > \"$WORK/list.txt\"\nffmpeg -v error -y -f concat -safe 0 -i \"$WORK/list.txt\" -c copy \"$WORK/video.mp4\" || die \"concat failed\"\nGOT=$(ffprobe -v error -count_packets -select_streams v:0 -show_entries stream=nb_read_packets -of csv=p=0 \"$WORK/video.mp4\")\n[[ \"$GOT\" -eq \"$TOTAL\" ]] || die \"joined video has $GOT frames, expected $TOTAL\"\n\nDUR=$(python3 -c \"print(f'{$TOTAL/$FPS:.6f}')\")\nAOFF_S=$(python3 -c \"print(f'{$AOFF/$FPS:.6f}')\")\nif [[ $NOAUDIO -eq 1 ]]; then\n ffmpeg -v error -y -i \"$WORK/video.mp4\" -map 0:v:0 -c copy -movflags +faststart \"$OUT_ABS\" || die \"copy failed\"\nelse\n step \"muxing $AUDIO (from ${AOFF_S}s)\"\n ffmpeg -v error -y -i \"$WORK/video.mp4\" -ss \"$AOFF_S\" -i \"$AUDIO\" -map 0:v:0 -map 1:a:0 -c:v copy \\\n -af apad -c:a aac -b:a 320k -ar 48000 -ac 2 -t \"$DUR\" -movflags +faststart \"$OUT_ABS\" || die \"mux failed\"\nfi\n\nSTATUS=0\nif [[ $NOCHECK -eq 0 ]]; then\n if [[ $NOAUDIO -eq 0 ]]; then\n python3 \"$SCRIPT_DIR/check-sync.py\" \"$OUT_ABS\" \"$AUDIO\" --ref-offset \"$AOFF_S\" --json \"out/qa/sync-$NAME.json\" >/dev/null || STATUS=1\n fi\n PARGS=(--spec \"${SIZE}@${FPS}\" --frames \"$TOTAL\" --json \"out/qa/probe-$NAME.json\" --no-loudness)\n [[ $NOAUDIO -eq 1 ]] && PARGS+=(--audio none)\n python3 \"$SCRIPT_DIR/probe.py\" \"$OUT_ABS\" ${PARGS[@]+\"${PARGS[@]}\"} >/dev/null || STATUS=1\nfi\nstep \"$( [[ $STATUS -eq 0 ]] && echo done || echo 'FAILED a gate' ): $OUT\"\nexit $STATUS\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":5913,"content_sha256":"16fc0886879ab0f510dff7eb7d39224a00ef471affad62c5e0e7a66deef6e44f"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"cinetic","type":"text"}]},{"type":"paragraph","content":[{"text":"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.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Engines.","type":"text","marks":[{"type":"strong"}]},{"text":" 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.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Numbers.","type":"text","marks":[{"type":"strong"}]},{"text":" 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.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Brand.","type":"text","marks":[{"type":"strong"}]},{"text":" When the user supplies a brand (colours, fonts, logo, footage, tone), the brand wins. The taste defaults cover the parts you have to invent.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Paths.","type":"text","marks":[{"type":"strong"}]},{"text":" Paths such as ","type":"text"},{"text":"scripts/render.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" work from the skill root and also inside a film project, because ","type":"text"},{"text":"scripts/new-film.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" copies every script into the project. Every script except the internal helpers ","type":"text"},{"text":"brand-svg.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" (which ","type":"text"},{"text":"brand-kit.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" calls) and ","type":"text"},{"text":"hf-preflight.cjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" (which ","type":"text"},{"text":"hf-finish.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" runs) prints ","type":"text"},{"text":"--help","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Worked example.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"references/worked-example.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 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.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"The bar","type":"text"}]},{"type":"paragraph","content":[{"text":"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.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Substance before polish.","type":"text","marks":[{"type":"strong"}]},{"text":" 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\".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Its own look.","type":"text","marks":[{"type":"strong"}]},{"text":" 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.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Ultra clean and smooth.","type":"text","marks":[{"type":"strong"}]},{"text":" 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.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Visual, not narrated.","type":"text","marks":[{"type":"strong"}]},{"text":" The idea reads with the sound off; the words confirm what the picture already said.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Weight and stillness.","type":"text","marks":[{"type":"strong"}]},{"text":" Motion has mass, arrives on the beat and then rests, so the fast moments land.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sound that makes the picture feel better:","type":"text","marks":[{"type":"strong"}]},{"text":" every hit is caused by something you can see.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Restraint over decoration.","type":"text","marks":[{"type":"strong"}]},{"text":" When in doubt, remove.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Proof, not impression.","type":"text","marks":[{"type":"strong"}]},{"text":" 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.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Hard bans","type":"text"}]},{"type":"paragraph","content":[{"text":"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. ","type":"text"},{"text":"lint-film.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" checks the tokens and styles for them, and the critics check the frames.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Words:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"eyebrow or kicker labels above a headline;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"stacked taglines, and \"Introducing…\";","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"full sentences dumped at once: copy arrives as short fragments, one after another, with picture between them (§4, \"Text flow\");","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"text walls, and random filler text: decorative mono captions, fake metrics, labels nobody needs, lorem ipsum.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Every word on screen earns its place.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Type:","type":"text","marks":[{"type":"strong"}]},{"text":" 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.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Colour:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"orange, amber, beige, cream, tan or sand, as an accent, a paper or a glow;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"neon: very bright, saturated accents, glows, bloom and halos;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"purple, violet or indigo, and purple-to-blue or any multi-hue gradient;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"glassmorphism.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Decoration:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"emoji and stock icons;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"sparkles standing for \"AI\", particles, confetti, lens flares and code rain;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"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).","type":"text"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"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.","type":"text"}]},{"type":"paragraph","content":[{"text":"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 ","type":"text"},{"text":"BRIEF.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" so the critics don't flag it, and mark the lines that set it with ","type":"text"},{"text":"// cinetic:brand-supplied \u003cwhat>","type":"text","marks":[{"type":"code_inline"}]},{"text":" so the lint accepts them.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"1. Formats and defaults","type":"text"}]},{"type":"paragraph","content":[{"text":"Choose a row first. It sets the length, the density and the list of files to deliver. The recipe for each format is in ","type":"text"},{"text":"references/formats.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". 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.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Format","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Length","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"fps","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Grid (BPM)","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Story beats","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Words: total · new per fragment · visible at once","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Sound","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Deliverables","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Launch film / teaser","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"20–45 s","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"60","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"100 (crisp 120)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"6–8 per 30 s (crisp 8–10)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"15–25, cap 30 · ≤ 4 · ≤ 8 (≤ 5 in replace mode)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"full score + SFX","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"16:9 master, 9:16 and 1:1 re-layouts, poster, brand kit if invented","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Product / feature video, update film","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"8–30 s","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"60","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"100","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1 capability per 8–10 s","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≤ 12 · ≤ 4 · ≤ 8","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UI SFX; calm: effects only at −18 LUFS (a light bed only when the brief wants a groove)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"master, poster","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Feature loop","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"4–15 s, whole bars","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"60 (GIF 25)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"any; whole bars","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3–5","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≤ 6, reads muted · ≤ 4 · ≤ 4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"optional","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP4, WebM, GIF, seamless seam","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Logo sting","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3–8 s","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"60","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"100–120","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2–4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"name + ≤ 3 · ≤ 3 · name + 3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"one tuned hit + tail","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP4, ProRes 4444 alphas (held and cleared), poster, brand kit if invented","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UI walkthrough","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"20–90 s","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"60","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"90–100","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1 chapter per 2–4 bars","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≤ 5 per caption · ≤ 4 · one caption","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bed + UI SFX","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"master, SRT captions","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"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.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"2. The five laws","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"One idea, specific to this product.","type":"text","marks":[{"type":"strong"}]},{"text":" You can say the film in one sentence, and the proof shows what this product does that the obvious or simpler version wouldn't. Restraint applies to decoration and copy, never to the product's intelligence: if the feature is smart, the film shows the smart part. ","type":"text"},{"text":"Why:","type":"text","marks":[{"type":"em"}]},{"text":" a viewer carries away one thing, and a film that would be equally true of a competitor, or of a dumber feature, gives them nothing to carry.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"One story device.","type":"text","marks":[{"type":"strong"}]},{"text":" An object, shape or colour travels through every shot and never blinks out at a cut. In a launch film it ideally resolves into the mark at the end; in a feature loop or product video it is the unit the product acts on (the card, the row, the file). Find the object this product owns. A bare dot, line or block is the lazy default, and it is also the look of this skill's own examples. ","type":"text"},{"text":"Why:","type":"text","marks":[{"type":"em"}]},{"text":" it makes the film continuous, it lets the film read with the sound off, and an owned object is what makes the film unmistakably this brand's.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"One accent with one meaning.","type":"text","marks":[{"type":"strong"}]},{"text":" The accent is a single token that means one thing, such as \"now\", \"new\" or \"yours\". Use it sparingly (about ≤ 8% of pixels) and let it flood the frame at most once. ","type":"text"},{"text":"Why:","type":"text","marks":[{"type":"em"}]},{"text":" a colour that means something is read without words, and a colour used everywhere means nothing.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"One house motion system.","type":"text","marks":[{"type":"strong"}]},{"text":" Use a few named curves and springs, each for a stated reason, and take every frame number from ","type":"text"},{"text":"timeline.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"Why:","type":"text","marks":[{"type":"em"}]},{"text":" consistent motion ","type":"text"},{"text":"is","type":"text","marks":[{"type":"em"}]},{"text":" brand motion, and ad-hoc eases read as generated.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify by measurement.","type":"text","marks":[{"type":"strong"}]},{"text":" A film is done only when the rendered pixels and the decoded audio pass the scripts and one round of critique. ","type":"text"},{"text":"Why:","type":"text","marks":[{"type":"em"}]},{"text":" Studio playback hides pops, ghost frames, sync slips and colour shifts that the encoded file will show.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"3. Workflow","type":"text"}]},{"type":"paragraph","content":[{"text":"Every step writes a file, and every gate is a check you actually run. Work inline. Subagents are for critique only (§8), because building frames in parallel costs more time than it saves.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 0: Intake → ","type":"text"},{"text":"BRIEF.md","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If the brief already states the product, the message and the length, infer everything else and write your assumptions down.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If it doesn't, ask at most 3 questions: the product in one line, the format and length, and where the film will be shown. Take everything else from §1.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Choose the engine: Remotion by default; HyperFrames if the user already has a HyperFrames project, wants HTML/GSAP, or needs ","type":"text"},{"text":"--batch","type":"text","marks":[{"type":"code_inline"}]},{"text":" renders driven by variables.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Copy the hard bans (above) into ","type":"text"},{"text":"BRIEF.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", plus any brand-supplied exceptions. Critics are later prompted with this file word for word, so it doubles as the QA contract.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Choose the pace: calm (the default) unless the brief asks for energy; write it into the spec line. Crisp is an explicit opt-in (§5).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Scaffold the project: ","type":"text"},{"text":"bash \u003cskill>/scripts/new-film.sh films/\u003cname> --fps 60 --size 1920x1080 [--engine hyperframes]","type":"text","marks":[{"type":"code_inline"}]},{"text":". It keeps the starter's own tempo (Remotion 100 BPM, HyperFrames 120) and the calm pace; add ","type":"text"},{"text":"--pace crisp","type":"text","marks":[{"type":"code_inline"}]},{"text":" for a crisp film (120 BPM and the crisp pace), or ","type":"text"},{"text":"--bpm N","type":"text","marks":[{"type":"code_inline"}]},{"text":" for another tempo. Pass ","type":"text"},{"text":"--link-modules \u003cdir>/node_modules","type":"text","marks":[{"type":"code_inline"}]},{"text":" to reuse an existing install instead of running ","type":"text"},{"text":"npm install","type":"text","marks":[{"type":"code_inline"}]},{"text":" (a junction on Windows).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gate:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"BRIEF.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" has a spec line, for example ","type":"text"},{"text":"1920x1080@60, 30s, 100BPM, pace: calm, audio: earshot kit (calm), no music","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 1: Concept → ","type":"text"},{"text":"TREATMENT.md","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/concept-and-story.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" now.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Start from specifics, not from style. Write down:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"what the name means or evokes (it often hands you the mark and the device);","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the feature's non-obvious behaviour, finishing the sentence \"unlike the obvious version, it…\";","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the product's own objects (its rows, cards, readings and states).","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Write 3 concepts, each through a different lens:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"(a) the product's own verb or metaphor made literal;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"(b) the viewer's pain made visible;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"(c) a formal device: a relay object, a bookend, one unbroken camera move, or a container that becomes the product.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Throw out any concept whose props could appear in another product's film. It isn't yours.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Draw the techniques; don't choose them.","type":"text","marks":[{"type":"strong"}]},{"text":" Run ","type":"text"},{"text":"python3 scripts/pick.py --format \u003claunch|feature|loop|sting|walkthrough|vertical> --energy \u003ccalm|medium|high> --seconds \u003clength> --json out/qa/picks.json","type":"text","marks":[{"type":"code_inline"}]},{"text":", with ","type":"text"},{"text":"--energy calm","type":"text","marks":[{"type":"code_inline"}]},{"text":" unless the brief asks for energy. It draws about one technique per 3.5 s of film when calm and per 2.5 s otherwise (9 or 12 for a 30 s launch, at least 4 for a short piece) from the measured library in ","type":"text"},{"text":"assets/library/techniques.json","type":"text","marks":[{"type":"code_inline"}]},{"text":", in the order the format needs them, weighted toward proven moves and the film's energy, and prints each recipe with default timing. Choosing by hand converges on the same fade-up, push-in and end card in every film; the draw is how two films stop looking alike. Build the picks the concept can carry, retold in its objects and device; the story, product truth and one consistent UI language come first, and a film that crams in every move reads as a showreel. Reroll a pick that fights the concept at most twice (","type":"text"},{"text":"--category \u003ccat> --exclude \u003cid>","type":"text","marks":[{"type":"code_inline"}]},{"text":"), or drop it, with a written reason; never add a technique the draw did not give you (draw one with ","type":"text"},{"text":"--category","type":"text","marks":[{"type":"code_inline"}]},{"text":"), and pass ","type":"text"},{"text":"--brand-supplied \u003clook>","type":"text","marks":[{"type":"code_inline"}]},{"text":" when the brand owns a banned look. Read a drawn entry in full with ","type":"text"},{"text":"pick.py --show \u003cid>","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"references/technique-library.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" lists every entry (search it, don't read it whole). Measured pacing, easing and text-timing norms are in ","type":"text"},{"text":"references/craft-rules.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fill in ","type":"text"},{"text":"assets/TREATMENT.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the concept you chose:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"a logline;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the arc: hook (inside the problem, felt by 1 s) → turn → proof (1–2 capabilities) → promise → lockup;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the drawn techniques with the seed line, each placed in a beat (§3b of the template);","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the device path, shot by shot;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"a beat sheet with the columns bar | time | picture | copy (fragment, mode) | picture beat | sound: every thought of more than 4 words written as a chain of fragments, each with the picture beat it hands over to;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the full copy with its word count;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the final frame.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run the deletion test. Remove the demo beats, and the value should still read. Remove the value beats; if the film still \"works\", it was a feature tour.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run the specificity test. Would the film be just as true of a simpler product, for example an even split instead of an itemised one, or a plain list instead of a ranked one? If yes, the proof undersells the product; show the part that is hard.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run the feature-word test. Watching muted, would a stranger use the brief's word for the feature (\"streak\", \"split\")? If a clever device makes it read as something else, keep the feature's familiar form and let the device dress it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gate:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the logline is ≤ 15 words;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the specificity test passes, with its answer written down;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the word count is inside the §1 budget;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"every fragment is 1–4 words; no beat shows more than 8 words of copy (5 in replace mode); every thought of more than 4 words is a chain with a picture beat of 30–90 f between its fragments, and none arrives as one block (a crisp film allows 5 words per fragment and 10 on screen);","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"copy is planned on at most about 65% of the runtime (80% in crisp), with a text-free picture stretch of at least 1.5 s in every 10 s;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the device appears in every beat row, and every drawn technique is placed in a beat or dropped with a reason.","type":"text"}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2: Brand and style frames → ","type":"text"},{"text":"src/brand/","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/brand-and-color.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and the type section of ","type":"text"},{"text":"references/copy-and-type.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The starter's palette, fonts, mark and demo name are stand-ins marked ","type":"text"},{"text":"// cinetic:placeholder","type":"text","marks":[{"type":"code_inline"}]},{"text":", and ","type":"text"},{"text":"lint-film.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" reports an error until every marker is gone. Apply the user's brand, or invent one for this film, then delete the markers. A film that keeps the starter's look looks like every other film made from it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Personality first.","type":"text","marks":[{"type":"strong"}]},{"text":" Write three adjectives for the brand, then derive the choices from them. This mapping is in ","type":"text"},{"text":"references/brand-and-color.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" §2:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the typeface: pick the sans for this brand from ","type":"text"},{"text":"@fontsource-variable/*","type":"text","marks":[{"type":"code_inline"}]},{"text":" and vendor it with ","type":"text"},{"text":"node scripts/add-font.mjs \u003cname>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (safe with a shared ","type":"text"},{"text":"node_modules","type":"text","marks":[{"type":"code_inline"}]},{"text":"); Geist is only the starter's stand-in;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"weight and case;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the stage (light or dark: a dev tool or a fire-named brand often wants dark), neutral or cool, and the accent's hue from the allowed families;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the motion character, from crisp to unhurried.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"A calm notes app and a fast CI tool should not share a look.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Write ","type":"text"},{"text":"src/brand/tokens.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"python3 scripts/palette.py --accent '#…' --stage dark","type":"text","marks":[{"type":"code_inline"}]},{"text":" builds the neutrals, checks contrast and refuses a banned hue):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"colours: an ink, a paper, 2–4 neutrals, and one accent with its meaning in a comment;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"type: one family at 2 weights, plus a mono if you need one;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"a type scale;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"one tracking value per role.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Explore the mark before you commit to one.","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sketch 6–10 directions, each from a different source: the name's meaning, a letterform, the product's own object, the idea's metaphor, and a pure geometric construction.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Render them together on one sheet and look at it (the ","type":"text"},{"text":"MarkSheet","type":"text","marks":[{"type":"code_inline"}]},{"text":" still in ","type":"text"},{"text":"references/brand-and-color.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" §4).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Reject any direction that could belong to ten other startups, including the \"geometric block + accent dot\" family, which is this skill's own example vocabulary.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run the misread test. Show the mark at 64 px for half a second. If it reads first as a common symbol (a minus, an emoticon, a menu or hamburger icon, a padlock, a play button, a plus), reject it or change it until it doesn't.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Build the winner in ","type":"text"},{"text":"src/brand/Mark.tsx","type":"text","marks":[{"type":"code_inline"}]},{"text":" on a 100-unit grid, with gaps of at least 8 units so it survives being scaled down.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A palette with character, inside the bans.","type":"text","marks":[{"type":"strong"}]},{"text":" Derive it from the world of the name and the product (a hillside gives pine, moss and slate; a foundry gives charcoal, soot and a glowing red), not from framework defaults. When the name's world is heat or fire, orange is banned, so go red-hot (a deep red glowing on a true charcoal) or white-hot, never generic dev-tool blue: the colour must carry the name. A blue-grey near-black stage is the stock dev-tool dark theme (","type":"text"},{"text":"palette.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" notes it), and in a dev tool red UI tags read as errors; spend the red on the brand's own thing.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Framework blues (the ","type":"text"},{"text":"#3B82F6","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"#2563EB","type":"text","marks":[{"type":"code_inline"}]},{"text":" family) and flat pure greys read as \"default\".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Tint the neutrals toward the world, with a cool green-grey or a blue slate; a dark stage can be pine-black or ink-navy rather than ","type":"text"},{"text":"#000","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/brand-and-color.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" §8–10 has worked palettes.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Set the lockup like a typographer.","type":"text","marks":[{"type":"strong"}]},{"text":" Render three settings side by side (mark-to-cap ratio, gap, weight, tracking) and pick the one where the mark and the word read as one object. A carelessly set wordmark costs more than any animation gains.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Render 2–4 one-frame style stills from the ","type":"text"},{"text":"Stills","type":"text","marks":[{"type":"code_inline"}]},{"text":" folder and look at them: the mark at 16 px and at full size, one statement frame, and one product frame. ","type":"text"},{"text":"npm run stills","type":"text","marks":[{"type":"code_inline"}]},{"text":" renders the mark stills; add your own with ","type":"text"},{"text":"npx remotion still \u003cStillId> out/stills/\u003cname>.png","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gate:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the stills pass §4;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the mark sheet and the chosen direction's reason are in ","type":"text"},{"text":"TREATMENT.md","type":"text","marks":[{"type":"code_inline"}]},{"text":";","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"node scripts/lint-film.mjs src","type":"text","marks":[{"type":"code_inline"}]},{"text":" reports no placeholder and no colour or font that is not a token.","type":"text"}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 3: Timeline → ","type":"text"},{"text":"src/timeline.ts","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/timing-grid.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" now.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Choose BPM and fps so that a beat is a whole number of frames (see §5).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Map the beat sheet onto bars:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ACT","type":"text","marks":[{"type":"code_inline"}]},{"text":" holds contiguous, bar-aligned acts;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"CUE","type":"text","marks":[{"type":"code_inline"}]},{"text":" holds named frames that sit on the grid;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"COPY","type":"text","marks":[{"type":"code_inline"}]},{"text":" holds one ","type":"text"},{"text":"{id, text, in, resolved, out}","type":"text","marks":[{"type":"code_inline"}]},{"text":" entry per fragment: ","type":"text"},{"text":"line(id, text, in)","type":"text","marks":[{"type":"code_inline"}]},{"text":" for a single fragment, ","type":"text"},{"text":"chain(id, 'build' | 'replace', [[text, inFrame], …])","type":"text","marks":[{"type":"code_inline"}]},{"text":" for a thought in several (it adds ","type":"text"},{"text":"chain","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"mode","type":"text","marks":[{"type":"code_inline"}]},{"text":" and times each fragment from the pace);","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"TOTAL","type":"text","marks":[{"type":"code_inline"}]},{"text":" includes a tail of at least 60 f.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Give each bar one motion peak, and let text and the hero action take turns on the grid.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gate:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"npx tsx scripts/grid-check.ts src/timeline.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" passes (","type":"text"},{"text":"npm run check","type":"text","marks":[{"type":"code_inline"}]},{"text":" runs it together with ","type":"text"},{"text":"tsc","type":"text","marks":[{"type":"code_inline"}]},{"text":" and the lint). It checks the grid (every cue on the 16th grid or carrying an ","type":"text"},{"text":"offgrid:","type":"text","marks":[{"type":"code_inline"}]},{"text":" reason), the reading holds, the event gap (no stretch past 72 f without an event or a reading hold; 48 f in crisp), the word budget, and the text flow, all against the film's ","type":"text"},{"text":"PACE_NAME","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"--pace","type":"text","marks":[{"type":"code_inline"}]},{"text":" overrides it): ","type":"text"},{"text":"fragment","type":"text","marks":[{"type":"code_inline"}]},{"text":" (words per entry), ","type":"text"},{"text":"copy-concurrent","type":"text","marks":[{"type":"code_inline"}]},{"text":" (words visible on any frame, a leaving fragment counted until its exit is over), ","type":"text"},{"text":"read-first","type":"text","marks":[{"type":"code_inline"}]},{"text":" (two separate lines never arrive together), ","type":"text"},{"text":"reveal-rate","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"chain-gap","type":"text","marks":[{"type":"code_inline"}]},{"text":" (each fragment read before the next, with 30–90 f of picture between), ","type":"text"},{"text":"chain-beat","type":"text","marks":[{"type":"code_inline"}]},{"text":" (a cue really happens in that picture beat), ","type":"text"},{"text":"read-quiet","type":"text","marks":[{"type":"code_inline"}]},{"text":" (no cue starts inside a read), ","type":"text"},{"text":"word-rate","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"text-share","type":"text","marks":[{"type":"code_inline"}]},{"text":". In a calm film a missing or short picture beat, two lines arriving together and copy on more than 75% of the body are errors, not warnings. A HyperFrames project runs it on its ","type":"text"},{"text":"timeline.js","type":"text","marks":[{"type":"code_inline"}]},{"text":": ","type":"text"},{"text":"node scripts/grid-check.ts timeline.js","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 4: Build, act by act → ","type":"text"},{"text":"src/acts/","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"Before you write motion, read ","type":"text"},{"text":"references/motion-tokens.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"references/camera.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"references/transitions.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", and ","type":"text"},{"text":"references/product-ui.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" if the product appears. Keep ","type":"text"},{"text":"references/chromium-rendering.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" open while you debug.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Make each act a pure function of ","type":"text"},{"text":"useCurrentFrame()","type":"text","marks":[{"type":"code_inline"}]},{"text":". Mount one ","type":"text"},{"text":"\u003cSequence>","type":"text","marks":[{"type":"code_inline"}]},{"text":" per act in ","type":"text"},{"text":"Film.tsx","type":"text","marks":[{"type":"code_inline"}]},{"text":", and register each act as its own composition in the ","type":"text"},{"text":"Acts","type":"text","marks":[{"type":"code_inline"}]},{"text":" folder so you can iterate on it alone.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Take all motion from the tokens in ","type":"text"},{"text":"src/lib/anim.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" (§6). A raw bezier inside an act is a lint error, because that is how a house style decays.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Build the product UI from real components fed by one ","type":"text"},{"text":"data.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" module with asserts on dates, weekdays, counts, plurals and sums. The data must agree with the story too: the person who paid doesn't owe, a \"sorted\" list is actually sorted, a stated ratio matches its numbers, and no number appears twice in one frame. That holds in every frame, in-between states included: a copy headed \"owes Maya\" over the whole bill's total is a wrong number on screen for as long as it shows. Frame the UI large enough to read on a phone: push in 2–5× on every interaction that carries the story, and hold a wide shot of readable UI for at most about 1.7 s. Every bar, segment, chart or bare number that carries meaning shows its value, label or unit: a row of \"18 24 12 31\" under M–S reads as dates, not pages. A screenshot never carries the hero shot.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Make the product and the feature identifiable. Name the feature once, as a UI label, the one line of copy or the end card, and show enough real app chrome that a stranger knows this is software. \"No feature name as a headline\" never means \"never name it\". A feature label is not a payoff: land the value with one short line that makes it human (\"Sam had the salad.\"), even in a muted loop.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Frame only the meaningful part of the product. No half-empty grids, no tables cropped at the frame edge, no rows of blank cells: they read as a spreadsheet, not a product.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Text that must be read stays readable in motion. Text being read (between its ","type":"text"},{"text":"resolved","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"out","type":"text","marks":[{"type":"code_inline"}]},{"text":" frames) moves at most about 3 px/f, and at least 0.3 px/f of drift or a slow creep keeps it alive; a camera cruise under it runs 1.5–3 px/f. Keep labelled chips and numbers under about 20 px/f while they travel, or fly them with the text faded and let it resolve on landing, because motion blur on small text reads as a double image.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Words reach the screen through ","type":"text"},{"text":"COPY","type":"text","marks":[{"type":"code_inline"}]},{"text":" only: render statements with ","type":"text"},{"text":"src/fx/Chain.tsx","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"Words","type":"text","marks":[{"type":"code_inline"}]},{"text":" from ","type":"text"},{"text":"copy(id)","type":"text","marks":[{"type":"code_inline"}]},{"text":", never a string literal in an act (","type":"text"},{"text":"lint-film.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" flags ","type":"text"},{"text":"copy-literal","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"text-spring","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"text-pace","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"typing-rate","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"per-letter","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Moving elements never cover text they pass over: plan the paths and the z-order, and check the densest frames at full size.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A progressive text reveal (letter by letter or with a sweep) must never spell a different word partway through (\"tarn\" briefly reading \"tar\"), and never shows part of a glyph: a soft wipe through an \"n\" reads as an \"r\" (\"halder\"). Step the mask from glyph edge to glyph edge on whole frames, or reveal whole words, and check the intermediate frames.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Export from its act every frame the sound needs, as a named constant (for example ","type":"text"},{"text":"LANDINGS","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"SNAP","type":"text","marks":[{"type":"code_inline"}]},{"text":"), so the cue export can import it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Wrap everything in ","type":"text"},{"text":"\u003cFontGate>","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Iterate each act in this loop:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Render stills at each cue −2, 0 and +2.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Make an act contact sheet: ","type":"text"},{"text":"python3 scripts/sheet.py --comp Act2 --every 2 --out out/qa/act2.png","type":"text","marks":[{"type":"code_inline"}]},{"text":" renders the act and tiles it with frame and time labels. Use ","type":"text"},{"text":"--every 2","type":"text","marks":[{"type":"code_inline"}]},{"text":" for fast sections.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run ","type":"text"},{"text":"bash scripts/layout-audit.sh Act2 --cues","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gate:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"lint-film.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" is clean;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"layout-audit","type":"text","marks":[{"type":"code_inline"}]},{"text":" reports 0 safe-area, overlap or ","type":"text"},{"text":"words","type":"text","marks":[{"type":"code_inline"}]},{"text":" violations (no frame with more readable film words than the pace allows; product strings carry ","type":"text"},{"text":"data-ui","type":"text","marks":[{"type":"code_inline"}]},{"text":" and count apart) and no readable text faster than 20 px/f; on the act sheets, text being read moves at most about 3 px/f;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"every act sheet has written review notes.","type":"text"}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 5: Sound → ","type":"text"},{"text":"public/audio/soundtrack.wav","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/sound.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" now.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run ","type":"text"},{"text":"npx tsx scripts/export-cues.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":", which writes ","type":"text"},{"text":"out/cues.json","type":"text","marks":[{"type":"code_inline"}]},{"text":". Sync points are computed from the picture code (spring contact frames, 50% pop frames, velocity peaks, 97% settles, a fragment's resolve frame) and never typed by hand. Typed sync points landed 8–11 f off in practice.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pick the sound's personality row in ","type":"text"},{"text":"references/sound.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" §5.5a first, and with it the kit preset: ","type":"text"},{"text":"calm","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the starters' default) for calm brands and text-led films, ","type":"text"},{"text":"crisp","type":"text","marks":[{"type":"code_inline"}]},{"text":" for fast and raw ones, ","type":"text"},{"text":"minimal","type":"text","marks":[{"type":"code_inline"}]},{"text":" for precise, expensive ones (","type":"text"},{"text":"python3 scripts/audio/kit.py init --preset crisp --force","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Drops onto silence and sub booms are for energetic launch films only.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The effects come from the earshot kit","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"audio/kit.json","type":"text","marks":[{"type":"code_inline"}]},{"text":", §1 of ","type":"text"},{"text":"sound.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"): one bank sound per role, rendered into ","type":"text"},{"text":"audio/kit/","type":"text","marks":[{"type":"code_inline"}]},{"text":" with measured sync points. Run ","type":"text"},{"text":"python3 scripts/audio/kit.py render","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"npm run kit","type":"text","marks":[{"type":"code_inline"}]},{"text":", cached) and ","type":"text"},{"text":"kit.py check","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"kit.py tune","type":"text","marks":[{"type":"code_inline"}]},{"text":" moves the tonal roles to ","type":"text"},{"text":"score.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"'s key after the key changes. Without earshot, ","type":"text"},{"text":"score.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" plays the takes already rendered in ","type":"text"},{"text":"audio/kit/","type":"text","marks":[{"type":"code_inline"}]},{"text":", and with none it falls back to its synth voices with a loud warning: install earshot (","type":"text"},{"text":"docs/setup.md","type":"text","marks":[{"type":"code_inline"}]},{"text":") rather than ship on them.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Text gets one soft sound per fragment","type":"text","marks":[{"type":"strong"}]},{"text":" (a ","type":"text"},{"text":"text","type":"text","marks":[{"type":"code_inline"}]},{"text":" cue on the frame it resolves), never one per word, and what was just said is read in near-silence (§5.7). ","type":"text"},{"text":"The lockup is a stop-down and ONE clean element:","type":"text","marks":[{"type":"strong"}]},{"text":" nothing in the 0.2–0.7 s before it, one ","type":"text"},{"text":"hit","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the frame the wordmark becomes readable (its ","type":"text"},{"text":"settleOf","type":"text","marks":[{"type":"code_inline"}]},{"text":"), no whoosh into it and no snap, bell or motif on top. ","type":"text"},{"text":"score.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"'s stack guard warns on anything else within ±3 f.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Music only when the film wants a groove.","type":"text","marks":[{"type":"strong"}]},{"text":" A calm, text-led film keeps ","type":"text"},{"text":"\"music\": \"none\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" (effects only, mastered at −18 LUFS); otherwise make the music drive the picture, whatever the personality:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A motif that follows the story's progress, for example one tuned note per step climbing the scale (","type":"text"},{"text":"progress","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":"score.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"; under the calm kit give the steps ","type":"text"},{"text":"\"kind\": \"bell\"","type":"text","marks":[{"type":"code_inline"}]},{"text":", its tuned voice: the calm pop, tick, click and light landing are untuned taps and cannot climb).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Section changes and the hero reveal land with the music's own event (drop, stop, re-entry). Designed effects go on discrete state changes only, about 5 per 10 s, clustered where the music is sparse; continuous motion and most cuts get none.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The drop on the hero reveal, 30–60% in, is the loudest moment, and ","type":"text"},{"text":"payoff","type":"text","marks":[{"type":"code_inline"}]},{"text":" points there. The lockup is the most resolved moment, not the loudest: the music stops on a bar line or filters down as the logo forms, then the one element and a 0.5–2.5 s tail. A sting is all lockup: its hit is its loudest moment.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The sound enters with intent on frame 0 (a drone or a 150–450 ms entry at most), without a click, and runs 5–12 LU under the body for the first 2–3 s. That assumes frame 0 moves: a drone at full level over a still frame (an empty page, a blinking caret) reads as a mistake, so move the picture, not the sound.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fill in ","type":"text"},{"text":"audio/score.json","type":"text","marks":[{"type":"code_inline"}]},{"text":": key, ","type":"text"},{"text":"music","type":"text","marks":[{"type":"code_inline"}]},{"text":", and with music one chord per bar, sections (with a breakdown or stop about 5 LU down at 40–80% of the runtime), drops and silences (or a ","type":"text"},{"text":"bed","type":"text","marks":[{"type":"code_inline"}]},{"text":": a real track). Keep ","type":"text"},{"text":"\"typing\": \"auto\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" unless the brand asks otherwise.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run ","type":"text"},{"text":"python3 scripts/audio/score.py --cues out/cues.json --score audio/score.json --out public/audio/soundtrack.wav --stems out/stems --json out/qa/score.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"npm run audio","type":"text","marks":[{"type":"code_inline"}]},{"text":"). The stems let ","type":"text"},{"text":"av-audit.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" check each sound against its cue; the master chain lives in ","type":"text"},{"text":"scripts/audio/master.py","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"With earshot, run ","type":"text"},{"text":"python3 scripts/audio/listen.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"npm run listen","type":"text","marks":[{"type":"code_inline"}]},{"text":"): earshot's timeline, sound sheet and critique in one process, into ","type":"text"},{"text":"out/qa/listen.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"out/qa/sound_sheet.png","type":"text","marks":[{"type":"code_inline"}]},{"text":" (§9.1). Read the cue sync first and open the sheet.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gate:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the WAV measures its target ±0.5 LUFS integrated (","type":"text"},{"text":"master.lufs","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":"audio/score.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"; without it −14 with music and −18 for an effects-only film; −16 only for a sting with music that plays at the head of other videos), with true peak ≤ −1.5 dBTP before encoding and the limiter under 1.5 dB at the payoff;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the report's ","type":"text"},{"text":"sfx","type":"text","marks":[{"type":"code_inline"}]},{"text":" line names the earshot kit (not ","type":"text"},{"text":"synth (fallback)","type":"text","marks":[{"type":"code_inline"}]},{"text":"), ","type":"text"},{"text":"kit.py check","type":"text","marks":[{"type":"code_inline"}]},{"text":" is clean, and no ","type":"text"},{"text":"stacked","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"rising","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"has no take on","type":"text","marks":[{"type":"code_inline"}]},{"text":" warning is left;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"a film with music of 20 s or more has contrast: LRA 5–8 LU, and the payoff 2–3 LU above the median momentary loudness (","type":"text"},{"text":"score.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" warns);","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"every sound has something visible that causes it, at a loudness that matches that cause (a caret blink whispers; the hero reveal drops); not every picture event gets a sound, but once a class of action is sounded every instance is, at one level.","type":"text"}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 6: Render and finish → ","type":"text"},{"text":"out/film.mp4","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/finishing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" now.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Preview:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"bash scripts/render.sh Film out/preview.mp4 --preview --audio public/audio/soundtrack.wav","type":"text","marks":[{"type":"code_inline"}]},{"text":". Remotion renders the picture muted and BT.709-tagged, and ffmpeg muxes the audio. Iterate and critique on previews only.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Master:","type":"text","marks":[{"type":"strong"}]},{"text":" if anything moves faster than 12 px/f (true of almost every launch film), ","type":"text"},{"text":"bash scripts/render.sh Film out/film.mp4 --blur","type":"text","marks":[{"type":"code_inline"}]},{"text":": sharp render → speed measurement → ","type":"text"},{"text":"FilmSub","type":"text","marks":[{"type":"code_inline"}]},{"text":" sub-frame render → float accumulation → mux → sync check. Otherwise, with no flag, ","type":"text"},{"text":"render.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" renders a sharp master at CRF 14. Loops take ","type":"text"},{"text":"--loop","type":"text","marks":[{"type":"code_inline"}]},{"text":" (lossless PNG intermediates, so the seam survives the encode) and, when silent, ","type":"text"},{"text":"--no-audio","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Blur costs render time, so spend it once.","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Render the blurred master once, at the end, after the last critique round. It prints the sub-frame multiple and an estimate before it starts; on 4 CPUs a 10 s film at 60 fps and 6× takes about 7 minutes on simple frames and 10–15 on dense UI. ","type":"text"},{"text":"--budget 6","type":"text","marks":[{"type":"code_inline"}]},{"text":" caps it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A late fix re-renders only the changed act's range (","type":"text"},{"text":"--frames A-B","type":"text","marks":[{"type":"code_inline"}]},{"text":") and splices it, rather than re-blurring the film.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A 9:16 or 1:1 variant with identical timing reuses the master's samples (","type":"text"},{"text":"--samples-from","type":"text","marks":[{"type":"code_inline"}]},{"text":"); one whose fastest motion is ≤ 12 px/f renders sharp.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Don't edit ","type":"text"},{"text":"src/","type":"text","marks":[{"type":"code_inline"}]},{"text":" while a render runs: ","type":"text"},{"text":"render.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" renders from a bundle frozen at its start and warns if the source changed meanwhile.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stop a render by its own PID, never with a ","type":"text"},{"text":"pkill -f","type":"text","marks":[{"type":"code_inline"}]},{"text":" pattern: on a shared machine the pattern also kills other people's renders.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Speed ceiling.","type":"text","marks":[{"type":"strong"}]},{"text":" Moves over about 80 px/f are listed as too fast for clean blur, and ","type":"text"},{"text":"render.sh --blur","type":"text","marks":[{"type":"code_inline"}]},{"text":" stops before the sub-frame pass while any are left: redesign them rather than adding samples (§6), or waive a frame-filling edge you checked at full size with ","type":"text"},{"text":"--accept-fast A-B","type":"text","marks":[{"type":"code_inline"}]},{"text":". Every hard cut is an act boundary: a cut inside an act blends its two shots into one double-exposed frame, so the render also stops on one (","type":"text"},{"text":"--accept-cut A-B","type":"text","marks":[{"type":"code_inline"}]},{"text":" for a flood or flash you checked).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gate.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"render.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" runs both checks below and exits 1 if either fails. Run them again on any file you deliver:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"python3 scripts/probe.py out/film.mp4 --spec 1920x1080@60 --dur \u003cs>","type":"text","marks":[{"type":"code_inline"}]},{"text":" passes: size, fps, duration ±1 f, yuv420p, BT.709 tags, AAC at 48 kHz;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"python3 scripts/check-sync.py out/film.mp4 public/audio/soundtrack.wav","type":"text","marks":[{"type":"code_inline"}]},{"text":" reports a lag of ≤ 48 samples and true peak ≤ −1 dBTP after decoding.","type":"text"}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 7: Review loop","type":"text"}]},{"type":"paragraph","content":[{"text":"Run §8 on previews. Launch films and walkthroughs get at least 2 rounds. For stings and loops whose scripts all pass, 1 round is enough. The blurred master then gets a check, not a round: forensics, av-audit, its fastest frames at full size (every act's, including the lockup's entrance and exit, where a sliding wordmark most often shows stepped copies; fix with ","type":"text"},{"text":"measure-speed.py --floor a-b:n","type":"text","marks":[{"type":"code_inline"}]},{"text":"), and a director's glance.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 8: Deliver","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run ","type":"text"},{"text":"bash scripts/deliver.sh out/film.mp4","type":"text","marks":[{"type":"code_inline"}]},{"text":" with the flags your format needs: ","type":"text"},{"text":"--loop --gif --webm","type":"text","marks":[{"type":"code_inline"}]},{"text":" for loops, ","type":"text"},{"text":"--alpha StingAlpha,StingAlphaClear","type":"text","marks":[{"type":"code_inline"}]},{"text":" for ProRes 4444, and ","type":"text"},{"text":"--variants Film9x16,Film1x1","type":"text","marks":[{"type":"code_inline"}]},{"text":" for social versions. It writes the poster (the final frame) and, in ","type":"text"},{"text":"qa/","type":"text","marks":[{"type":"code_inline"}]},{"text":", a manifest. For a brand you invented, ","type":"text"},{"text":"bash scripts/brand-kit.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" exports the mark, lockups, favicon sizes and avatar. Re-lay the social versions out from the same timeline; never crop the master (see ","type":"text"},{"text":"references/formats.md","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Write a short ","type":"text"},{"text":"README.md","type":"text","marks":[{"type":"code_inline"}]},{"text":": the idea, the device, the grid, how to render, and the file list.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Keep the delivery folder clean. Put the deliverables at its top level (the film, alpha versions, poster, logo files) and QA material (sheets, crops, reports) in a ","type":"text"},{"text":"qa/","type":"text","marks":[{"type":"code_inline"}]},{"text":" subfolder. A client opens the folder before they open the film.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gate:","type":"text","marks":[{"type":"strong"}]},{"text":" every deliverable in the §1 row exists and passes ","type":"text"},{"text":"probe.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" at its own spec.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Scaled-down path (stings, loops, short feature clips)","type":"text"}]},{"type":"paragraph","content":[{"text":"Small jobs should stay fast. The laws, the grid, the lint and one review round still apply. The rest shrinks:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Write a one-page treatment in ","type":"text"},{"text":"TREATMENT.md","type":"text","marks":[{"type":"code_inline"}]},{"text":": logline, device, the ","type":"text"},{"text":"pick.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" draw for the format, a bar table of 2–8 rows, and the copy. It replaces Steps 1–3 as separate passes. Still write ","type":"text"},{"text":"timeline.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" and run ","type":"text"},{"text":"grid-check.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For style frames, the mark at 16 px and one hero still are enough.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Build the whole piece as one act, or two.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sound is optional for a loop. A sting needs one hit tuned to the key, plus a tail that decays to digital zero.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Render a sharp master unless something passes 12 px/f. Then run one round with the director and forensics lenses.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For a loop, the last frame must flow into frame 0: the seam's frame difference must be at most max(0.4, 1.5× the median step at the ends) (","type":"text"},{"text":"forensics.py --loop","type":"text","marks":[{"type":"code_inline"}]},{"text":" checks it, and so does ","type":"text"},{"text":"deliver.sh --loop","type":"text","marks":[{"type":"code_inline"}]},{"text":"); render it with ","type":"text"},{"text":"render.sh --loop","type":"text","marks":[{"type":"code_inline"}]},{"text":". Build the loop as a cycle rather than as enter, hold and exit. Frame 0 is also the poster and the first frame a visitor sees, so compose it with the product's name or chrome on screen.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"4. Taste rules that matter most","type":"text"}]},{"type":"paragraph","content":[{"text":"The full catalogue of cheap-looking tells, each with its fix, is in ","type":"text"},{"text":"references/taste-and-slop.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Search it during review.","type":"text"}]},{"type":"paragraph","content":[{"text":"These rules ban the tells of generated work, not personality. Two films made with this skill for different brands should not look alike. If yours looks like the starter, the Tessel example or your last film, it is not finished.","type":"text"}]},{"type":"paragraph","content":[{"text":"Copy","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"references/copy-and-type.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Open inside the problem, never on \"Introducing…\", a rhetorical question or the logo. A muted feed decides in the first second. An update film opens on the news itself: the product already moving, with a setup fragment that hands over to it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pay off the film's own words: a setup phrase, then a refrain, then the resolution.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Avoid stock phrasing (seamless, unlock, AI-powered, streamline) and never use a feature name as a headline. They mark the film as a template.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Show one fragment at a time, and let text and the hero action take turns. No eyebrow or kicker labels and no stacked taglines, because that is landing-page grammar.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Text flow: fragments, one at a time","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"references/copy-and-type.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" §2b)","type":"text"}]},{"type":"paragraph","content":[{"text":"In eight frame-measured professional films a beat showed about 2 new words, a fragment stayed readable about 0.8 s, and half the beats continued a sentence begun on an earlier one. The house default keeps that grammar and slows it down.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fragments.","type":"text","marks":[{"type":"strong"}]},{"text":" 1–4 words (2–3 is best, about 22 characters at most; crisp allows 5), cut at grammar: after the subject, before the object, before the payoff noun or the name. Never split a name or a number from its unit. Punctuation comes only with a sentence's last fragment. A thought of more than 4 words is a chain of 2–4 fragments and never lands as one block.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Build or replace.","type":"text","marks":[{"type":"strong"}]},{"text":" In a build chain the fragments append to one held line that re-centres, the newest arriving in the accent and relaxing to ink; up to 8 words show, and the line clears as a whole. In a replace chain each fragment leaves whole over 24 f, then the next rises in its place (","type":"text"},{"text":"chain()","type":"text","marks":[{"type":"code_inline"}]},{"text":" ends the exit 6 f before the next fragment starts, so two never share the spot); up to 5 words show.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The animation is the verb.","type":"text","marks":[{"type":"strong"}]},{"text":" Put 2–4 words before the demo and 1–3 after it; the words never describe what the picture shows. The cliffhanger cut ends a fragment one step short of its payoff and lets the picture deliver it within 90 f: \"here is the newest\" → the product arrives → \"earshot.\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Text and picture take turns.","type":"text","marks":[{"type":"strong"}]},{"text":" Between two fragments of a chain the picture gets 30–90 f to act. The hero move never starts in the same 12 f as a fragment, nothing fast moves while words are being read, and nothing new starts during a reading hold: the camera drifts, the device breathes and the mix thins, so the line is read in quiet. Between runs of text the picture has stretches with no copy: copy on at most about 65% of the frames, and at least 1.5 s without text in every 10 s.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"More grammar.","type":"text","marks":[{"type":"strong"}]},{"text":" A list becomes a stem with a rotating slot (\"syncs to\" | \"your phone\" → \"your laptop\" → \"the whole team.\"). The product can stand in for a word inside the line. One giant keyword with a small continuation beside it, once per film. Edit the tail and keep the anchor instead of replacing a line. A persistent container (a pill, brackets) holds changing words together.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Emphasis by colour, never bounce.","type":"text","marks":[{"type":"strong"}]},{"text":" One accent sweep on the keyword, or the last word keeps the accent. No overshoot, slam per line or per-letter trick on copy; only the one hero word may move letter by letter.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Holds are alive, not frozen.","type":"text","marks":[{"type":"strong"}]},{"text":" A fragment holds max(60, 42 + 8 f per word) after it resolves (1.0–1.25 s for 1–4 words; a build line counts all its words: about 106 f at 8 words) while it drifts 0.3–1 px/f or creeps 1–3%. That is life, not a stall: no tick needs planting in the read.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Type","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Choose one clean, modern sans for the brand's personality, at 2 weights, plus an optional mono, shipped as local variable fonts (","type":"text"},{"text":"@fontsource-variable/*","type":"text","marks":[{"type":"code_inline"}]},{"text":"). No serif and no italic (see Hard bans).","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Personality comes from the sans you pick, its weight, case and spacing: a humanist sans at a light weight for calm, a tight grotesk for fast, a rounded sans for friendly.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Condensed display faces are a generated-look tell.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The ubiquitous UI default families, the starter's stand-in included, read as \"template\" unless you chose them on purpose.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Weight carries tone: a calm brand rarely wants a heavy grotesk wordmark.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Get emphasis from size or motion.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Statements are 88–128 px with leading 0.95–1.05; a build line that will reach 6–8 words takes the quieter line role, 48–72 px, with generous space around it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Emphasis inside a line is 1.5–1.7× the statement size; a standalone hero word may run 2–5× (14–48% of the frame height); secondary lines are 44–56 px.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"UI must be at least 22 px on screen after camera scale.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every number uses tabular figures.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Keep one tracking token per role, for example a display setting of 600 / −0.05 em. Near-miss values across scenes read as \"almost matched\".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No entry blur on text by default; ≤ 4 px when the brand is soft, and 6–8 px on statements only in the crisp profile, always through ","type":"text"},{"text":"blurIn","type":"text","marks":[{"type":"code_inline"}]},{"text":" (clear by 60% of the eased move, because Chromium steps blur radii and renders anything under ~0.75 px fully sharp). Text lands sharp and is never left readable-but-blurred for more than 6 f.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Colour","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"references/brand-and-color.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pick one accent from an allowed hue family (red, green, teal, blue, yellow) for a stated reason. Everything in the Hard bans is out: orange, amber, beige and cream, neon, purple, violet or indigo, multi-hue gradients and glassmorphism.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Restraint is not the same as colourless. The accent visibly carries the key beats (the product's action and the payoff), and the stage and neutrals have a tone from the brand's world. Check a mid-film frame at thumbnail size: if no brand colour shows, the film has no identity.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Tint the neutrals cool or keep them neutral, never warm or beige, and keep ink as ink. New things arrive in the accent and relax to neutral over 10–30 f, which is how colour says \"just happened\".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No glow, halo or bloom. Shadows are ","type":"text"},{"text":"0 20–40px 60–120px rgba(0,0,0,.12–.22)","type":"text","marks":[{"type":"code_inline"}]},{"text":", dark surfaces get a 1 px top hairline at 8–12% white, and gradients ship as dithered PNGs (","type":"text"},{"text":"scripts/dither-gradient.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"), because CSS gradients band at 8 bits.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Layout","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Avoid \"text left, UI card right\" and small centred floating cards (under about 40% of the frame width) on an empty field; a centred product window at 70–92% of the frame width, ideally overflowing an edge, is the norm. Show the product full-bleed, anchor the type to an eye line or the lower third, and give each shot one focal action.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Keep safe margins of at least 96 px at the sides and 64 px top and bottom, and at least 24 px of headroom at maximum punch. Overscan moving layers by 5–8%.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fill the frame with purpose.","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"In product shots the hero (the UI, the object, the number) covers about 40–70% of the frame.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Outside designed negative space around a single hero, such as a logo, no region of more than about a quarter of the frame stays empty.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"In vertical feeds, the platform's caption and button zones get background or continuation (the product's surface, the stage, a bleed), not an empty band. Viewers read emptiness as unfinished, whatever the reason.","type":"text"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Decoration","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every element must name its job (reveal, route, validate, emphasise) or be cut. That rules out particles, bokeh, starfields and ghost text.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Grain is either absent or global at 1.5–2%, keyed to the output frame.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Product truth","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"references/product-ui.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"): named, specific, consistent data; counters count on each visible event; the product only replans the future; no cursor once the product acts on its own; any paused frame still makes sense.","type":"text"}]},{"type":"paragraph","content":[{"text":"Transitions","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"references/transitions.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Use 6–8 types per film, each at most twice, plus one signature move taken from the mark and used 3 times (open, middle, close).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A crossfade is never the default, and never goes through black (it dips about 25% in luminance).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No preset gimmicks: glitch, light leak, film burn, swirl, ripple, dreamy zoom, flash through white.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"5. Timing system essentials","type":"text"}]},{"type":"paragraph","content":[{"text":"src/timeline.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the only source of timing. Picture, cue export and grid check all import it. Details and the full pattern are in ","type":"text"},{"text":"references/timing-grid.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"ts"},"content":[{"text":"export const BEAT = (60 / BPM) * FPS; // 36 f at 100 BPM / 60 fps\nexport const BAR = BEAT * 4;\nexport const b = (bar: number, beat = 0, sub = 0) => // sub = 16ths\n Math.round(((bar - 1) * 4 + beat) * BEAT + sub * (BEAT / 4));\nconst L = (abs: number) => abs - ACT.plan.from; // act-local frames, one per act","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Whole-frame beats at 60 fps:","type":"text","marks":[{"type":"strong"}]},{"text":" 90 BPM gives 40 f, 100 → 36, 120 → 30, 144 → 25, 150 → 24. 100 BPM is the default for text-led and calm films: a fragment, its read and the picture beat after it take 1–1.5 of its 2.4 s bars. 120 BPM (a 2 s bar) is for crisp films.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pace: calm by default, crisp on request.","type":"text","marks":[{"type":"strong"}]},{"text":" The profiles are ","type":"text"},{"text":"PACE.calm","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"PACE.crisp","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"src/lib/pace.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"motion.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" in HyperFrames). A film picks one with ","type":"text"},{"text":"PACE_NAME","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":"timeline.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"anim.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" exports it as ","type":"text"},{"text":"pace","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"line()","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"chain()","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Words","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"Chain","type":"text","marks":[{"type":"code_inline"}]},{"text":" take their numbers from it, and ","type":"text"},{"text":"grid-check.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" checks against it. Both profiles cut copy into fragments; crisp is faster and allows a little more text.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Knob (60 fps)","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"calm (default)","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"crisp","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Word entry, stagger","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"36 f on ","type":"text"},{"text":"outSoft","type":"text","marks":[{"type":"code_inline"}]},{"text":", 11 f","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"26 f on ","type":"text"},{"text":"out","type":"text","marks":[{"type":"code_inline"}]},{"text":", 6 f","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rise, entry blur","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"0.2 em, none","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"0.32 em, 8 px","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Exit","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the whole fragment together, 24 f","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"per word, 16 f","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reading hold","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"max(60, 42 + 8 f per word)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"36 f + 6 f per word","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Events per 30 s, visible change","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"24–36, about every 1 s","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"45–60, about every 0.5 s","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Shot median, maximum","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"100–160 f, 240 f","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"60–90 f, 180 f","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Words per fragment, visible at once","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≤ 4, ≤ 8 (≤ 5 in replace mode)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≤ 5, ≤ 10","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Copy rate, text share","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≤ 1.2 words/s in any 5 s, copy on ≤ 65% of frames","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≤ 2 words/s, ≤ 80%","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tempo","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"100 BPM","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"120 BPM","type":"text"}]}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Grid placement","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Section changes land on downbeats; cuts inside a section land on 8ths.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A batch of landings snaps to 16ths with a 0–3 f spread, and the first item lands on the beat.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Micro-rhythm is welcome: the payoff on the clap, its consequence on the next kick.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Cuts land on the grid, but most carry no sound of their own: the music carries them. Section changes and the hero reveal land with the music's own event (drop, stop, re-entry); ","type":"text"},{"text":"av-audit.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" suggests one when a top-3 picture change has no audio onset.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Hit frames","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Start a picture change at ","type":"text"},{"text":"cue − 1","type":"text","marks":[{"type":"code_inline"}]},{"text":", because ","type":"text"},{"text":"prog()","type":"text","marks":[{"type":"code_inline"}]},{"text":" is 0 on its start frame.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pulses peak 2 f after their sound (","type":"text"},{"text":"hitPulse","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Start a spring at ","type":"text"},{"text":"beat − delayTo(cfg, 0.5)","type":"text","marks":[{"type":"code_inline"}]},{"text":" so its pop lands on the beat.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Holds","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Once a fragment resolves, it holds at least max(60, 42 + 8 f per word) (crisp: 36 f + 6 f per word); a build line holds for its full word count (about 106 f at 8 words). Text that hasn't resolved never exits.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Nothing freezes. A reading hold with a 0.3–1 px/f drift or a 1–3% creep is alive, not a stall, and needs no tick planted inside it. Outside reading holds, no stretch runs past 72 f (crisp: 48 f) without a visible event, except a designed freeze of ≤ 15 f that sits exactly on a silence, and one designed near-still hold of 60–96 f per film on the key claim or success state, under a music dropout and followed by a big move (declare it as ","type":"text"},{"text":"export const HOLD = {from, to}","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":"timeline.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":"). When a gate flags a quiet stretch, give it life; loosening the threshold to pass is not a fix.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The end card runs 1.5–2.5 s: the lockup assembles over 60–80 f, then holds still for 36–80 f or keeps building (a creep of ≤ 6%, or a 10–20% push when the hold runs past about 1.3 s), then the tail. Never cut to a complete card that sits static, and never more than about 2.5 s on a still end card: a longer one reads as a drag. A URL is readable for at least 1.7 s.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"In a launch film or product video the lockup appears once, at the end (a sting is the exception: it is all lockup). A mid-film logo reveal followed by the same lockup again reads as a repeat and spends seconds on a still; the one exception is the product's name or mark revealed at about 20–26% of the runtime after a problem act of 10 s or more, when the end card is a different construction or mirrors the opening. Mid-film, the brand is present through the device and the accent. Brand logo time across the whole film stays ≤ about 12% of the runtime.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Density","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Calm: 24–36 discrete events per 30 s, one hero move per bar, and slow life cruising between them. Crisp: 45–60 events per 30 s, one motion peak per bar with 30–70 f of calm between peaks, and the first 6 s about twice as dense as the middle.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Calm is not sparse. The energy arc, the moving frame 0 and the moving last third stay; peaks come from one hero move per section, never from more text.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Energy builds toward the payoff. No stretch after the turn is slower or greyer than the one before it, except one designed lull of 1–4 s inside a music breakdown at about 55–85% of the runtime; the last and biggest visual peak lands at 75–90%, on the music's re-entry. A second half that drags loses films whose first half is strong. Check the energy by eye on a contact sheet of the second half.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The last third keeps moving: a camera push, secondary motion or the device still acting. A film that settles 4 s before the end has ended early.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Shot length: median 100–160 f and maximum 240 f in a calm cut-driven film (crisp: 60–90 f and 180 f); minimum 24 f, and only with no text; a continuous take may run 300–560 f if an in-shot beat lands at least every 72 f.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Hook:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"frame 0 is already composed and moving;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the sound enters with intent on frame 0, with no fade over the first bar;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"something visibly changes about every 1 s (crisp: 0.5 s), with slow life in between; when the film opens on words, the first fragment is readable by about 1 s;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the problem is felt by 1 s and stated by 2 s;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the product is named on screen by about a quarter of the runtime (its chrome, the name in a status line, the mark), not saved for the end card.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"A soft first second loses a muted feed.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"End card:","type":"text","marks":[{"type":"strong"}]},{"text":" carry the brief's one practical fact when it gives one (a date such as \"next week\", a URL, \"out now\"), set at the secondary size under the lockup. When the brief gives none, a launch film or product video still says what the product is in one short line under the name (\"The build cache for CI.\"); a bare name is for stings. That is the only line besides the name, and it arrives as its own fragment once the name has resolved, never stacked in together with it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"State follows the event:","type":"text","marks":[{"type":"strong"}]},{"text":" counters, checks and totals change on the landing frame of the thing that caused them, not a beat later.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Where a sound goes","type":"text","marks":[{"type":"strong"}]},{"text":" (computed by ","type":"text"},{"text":"src/lib/sync.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":": ","type":"text"},{"text":"peak","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"hit","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"delayTo","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"settleOf","type":"text","marks":[{"type":"code_inline"}]},{"text":"):","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Picture event","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The sound sits on","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Landing / impact","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the contact frame, so the motion must arrive with velocity (spring threshold 0.92–1.0, or the ","type":"text"},{"text":"contact","type":"text","marks":[{"type":"code_inline"}]},{"text":" ease)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Pop-in","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the frame at 50% of travel","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tween settle","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the 97% frame","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Whoosh (camera-scale moves and logo zooms, about 2 a film)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"onset 100–450 ms ahead, apex on the visual peak","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Typing","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"per key up to 25 chars/s, one blip per word above (","type":"text"},{"text":"\"typing\": \"auto\"","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A text fragment landing","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"one soft ","type":"text"},{"text":"text","type":"text","marks":[{"type":"code_inline"}]},{"text":" sound on the frame its last word visibly comes to rest (97% of its drawn rise: ","type":"text"},{"text":"chainResolves()","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":"Chain.tsx","type":"text","marks":[{"type":"code_inline"}]},{"text":", given the size the act sets the chain at), or none; never one per word","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Exit roll","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"one tick, with no detent","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Cut, scroll, stream, progress bar, rolling counter","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"nothing: the music carries it; a counter's final lock gets a crisp tick","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Audio that leads the picture by more than about 45 ms reads as \"sound first\". Move the picture, not the sound.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"6. Motion system essentials","type":"text"}]},{"type":"paragraph","content":[{"text":"The tokens live in ","type":"text"},{"text":"src/lib/anim.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"E","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"SPR","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"PACE","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"pace","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"durFor","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"tw","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"prog","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"mix","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"lmix","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"hitPulse","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"blurIn","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"arrive","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"arriveK","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"stagger","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"inertia","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"rand","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"fd","type":"text","marks":[{"type":"code_inline"}]},{"text":"), and tested primitives for the commonest moves in ","type":"text"},{"text":"src/fx/","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"Chain","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Words","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"TypeOn","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Scramble","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Roll","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Morph","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Iris","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"MaskRise","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Cursor","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"RackFocus","type":"text","marks":[{"type":"code_inline"}]},{"text":"): use them before writing your own. Curves, measured values and choreography constants are in ","type":"text"},{"text":"references/motion-tokens.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"; camera work is in ","type":"text"},{"text":"references/camera.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Token","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Bezier","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"out","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".16,1,.3,1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"arrivals, reveals","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"outSoft","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".22,1,.36,1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gentle settles","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"in","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".7,0,.84,0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"implosions, a hard snap out","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"exit","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".55,.055,.675,.19","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"exits into a cut: about ×1.14 per frame over the last 10–24 f","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"inOut","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".87,0,.13,1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"whips, lockup slides","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"smooth","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".65,0,.35,1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"drifts, fades (10–14 f)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ui","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".4,0,.2,1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"small UI changes","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cam","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".48,.1,0,.9","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"camera: slow start, peak at about 27%, long settle","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"glide","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".47,.2,.15,1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"150–350 px moves","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"rest","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".45,0,.1,1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"moves that end at rest; chaining after another move","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"contact","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".55,0,.9,.55","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"accelerating into an impact","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"whip","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".6,0,.15,1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"feature-to-feature whips","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dolly","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".35,0,.65,1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slow push through a hold","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"A film uses about 3 easing characters. Linear is only for drift.","type":"text"}]},{"type":"paragraph","content":[{"text":"Springs (","type":"text"},{"text":"SPR","type":"text","marks":[{"type":"code_inline"}]},{"text":", as damping/stiffness/mass): ","type":"text"},{"text":"snap","type":"text","marks":[{"type":"code_inline"}]},{"text":" 18/260/0.7 (small overshoot), ","type":"text"},{"text":"pop","type":"text","marks":[{"type":"code_inline"}]},{"text":" 11/180/0.6 (a landing only), ","type":"text"},{"text":"soft","type":"text","marks":[{"type":"code_inline"}]},{"text":" 26/120/1 (no overshoot), ","type":"text"},{"text":"heavy","type":"text","marks":[{"type":"code_inline"}]},{"text":" 30/90/1.4 (weighty), ","type":"text"},{"text":"calm","type":"text","marks":[{"type":"code_inline"}]},{"text":" 16/64/1 (critically damped, 97% at 41 f: the only spring for type).","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Damping.","type":"text","marks":[{"type":"strong"}]},{"text":" Everything is eased or critically damped: damping ≥ 2√(stiffness·mass). Overshoot (1–7%) belongs only to a true landing, an object arriving at a surface or a lock, at most 2 per film, because a bounce anywhere else reads as a toy. Never put an overshooting spring on type: words settle on an ease or ","type":"text"},{"text":"SPR.calm","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Smoothness is a ship gate.","type":"text","marks":[{"type":"strong"}]},{"text":" Jitter, pixel-snap stairs, single-frame pops and stall-then-lurch block the ship however good the idea is: zero ","type":"text"},{"text":"forensics.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" fails and Finish at 5 (§8).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Clamp.","type":"text","marks":[{"type":"strong"}]},{"text":" Clamp overshoot wherever it could collide with other geometry or drive a value negative. Snap to rest when ","type":"text"},{"text":"|1 − s| \u003c 0.01","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Easing.spring({damping:200})","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" starts at zero velocity, so it is not a replacement for expo-out.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Laws of weight:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Direction.","type":"text","marks":[{"type":"strong"}]},{"text":" Entrances decay and exits accelerate; only the camera eases both ways. A fade-out never uses expo-out, because it pops half the change in one frame.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Log space.","type":"text","marks":[{"type":"strong"}]},{"text":" Interpolate scale and zoom in log space: ","type":"text"},{"text":"exp(mix(log a, log b, t))","type":"text","marks":[{"type":"code_inline"}]},{"text":", which is ","type":"text"},{"text":"lmix","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Duration.","type":"text","marks":[{"type":"strong"}]},{"text":" Duration grows with distance: calm 0.5 s + 1.6 ms per px, camera moves clamped to 0.9–3.0 s (crisp: 0.35 s + 1.35 ms per px, 0.6–2.4 s). The slowest move is at least 3× the fastest.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Shape and contact.","type":"text","marks":[{"type":"strong"}]},{"text":" Shape settles 6 f before position. Contacts squash (scaleX 1.08 → 1, scaleY 0.9 → 1 over 8 f, anchored at the contact edge).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Overlap.","type":"text","marks":[{"type":"strong"}]},{"text":" A secondary move starts at 55–75% of the leading move (about 20 f into a 60 f move), so both settle within 2 f, or it starts on a zero-slope curve. This prevents stall-then-lurch.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Budget.","type":"text","marks":[{"type":"strong"}]},{"text":" Run one hero motion plus at most 2 supporting ones. Ordinary moves stay at or under 25 px/f; fast moves are rare (in calm at most 2 per film, at most 1 of them a 60–70 px/f whip, crisp films spend more), sit next to real stillness (0.3–0.6 px/f), and never happen while text is being read. Anything faster than 12 px/f gets true motion blur in Step 6.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Ceiling.","type":"text","marks":[{"type":"strong"}]},{"text":" No element moves faster than about 60–80 px/f. Past that, blur smears it into a streak with stepped copies; redesign the move as a cut on the beat, a match cut, a mask wipe or a shorter distance. Frame-filling edges (wipes, floods, irises, zoom-throughs, a container growing to full frame) may peak at 150–300 px/f for a few frames.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Envelope.","type":"text","marks":[{"type":"strong"}]},{"text":" Every punch and tick uses ","type":"text"},{"text":"hitPulse(t, attack = 2, tau = 5)","type":"text","marks":[{"type":"code_inline"}]},{"text":". A half-sine on a hard window leaves velocity clunks and peaks 6–8 f late.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Seams","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Either both sides of a cut are at rest, or the incoming shot starts at the outgoing velocity.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Match every property across a cut, and show the rest pose once.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never cross-fade two copies of one object; morph one object instead.","type":"text"}]}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"7. Engine non-negotiables","type":"text"}]},{"type":"paragraph","content":[{"text":"These come from real failures. Each costs minutes to respect and hours to debug.","type":"text"}]},{"type":"paragraph","content":[{"text":"Both engines","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Everything is a function of frame or time.","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No CSS ","type":"text"},{"text":"transition","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"animation","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"@keyframes","type":"text","marks":[{"type":"code_inline"}]},{"text":", because they don't render deterministically.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No ","type":"text"},{"text":"Math.random","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Date.now","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"performance.now","type":"text","marks":[{"type":"code_inline"}]},{"text":"; use a seeded ","type":"text"},{"text":"rand(seed)","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Transforms","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Put all translation inside one ","type":"text"},{"text":"transform","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"left:0; top:0; transform-origin:0 0; transform: translate() scale()","type":"text","marks":[{"type":"code_inline"}]},{"text":"). A fractional left/top combined with a transform snaps to whole pixels.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Place dots and cursors by transform only.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Vertical text positions snap to whole pixels in the headless shell, so end a text rise on ","type":"text"},{"text":"arrive(p)","type":"text","marks":[{"type":"code_inline"}]},{"text":" (and start an exit on ","type":"text"},{"text":"depart(q)","type":"text","marks":[{"type":"code_inline"}]},{"text":") instead of letting an ease-out creep its last pixels (","type":"text"},{"text":"references/chromium-rendering.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" §15).","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Blur","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never put ","type":"text"},{"text":"will-change","type":"text","marks":[{"type":"code_inline"}]},{"text":" on a layer where ","type":"text"},{"text":"filter: blur()","type":"text","marks":[{"type":"code_inline"}]},{"text":" animates. It causes nondeterministic ghost frames.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never animate blur on large layers; use ","type":"text"},{"text":"src/fx/RackFocus.tsx","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Discrete state","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Clamp every value that feeds geometry, so radius, width, blur and scale stay ≥ 0.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Compute discrete state (typed count, caret, counters, labels) from ","type":"text"},{"text":"fd(frame)","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"Math.round","type":"text","marks":[{"type":"code_inline"}]},{"text":"), because motion-blur sub-frames are fractional.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fonts.","type":"text","marks":[{"type":"strong"}]},{"text":" Load fonts before any render or text measurement (","type":"text"},{"text":"FontGate","type":"text","marks":[{"type":"code_inline"}]},{"text":"). A measurement taken earlier is cached with the fallback width.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Remotion","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"references/remotion-engine.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Clamping.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"interpolate","type":"text","marks":[{"type":"code_inline"}]},{"text":" extrapolates by default, so always clamp; ","type":"text"},{"text":"tw()","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"prog()","type":"text","marks":[{"type":"code_inline"}]},{"text":" do it for you.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"premountFor","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" only affects Studio preview and does nothing in a render. With ","type":"text"},{"text":"layout=\"none\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" it is silently ignored in 4.0.529 (no error). Don't build on it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Studio ","type":"text","marks":[{"type":"strong"}]},{"text":"Interactive.*","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" markup","type":"text","marks":[{"type":"strong"}]},{"text":" is an optional leaf layer, never the structure.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Config.","type":"text","marks":[{"type":"strong"}]},{"text":" Render with ","type":"text"},{"text":"--muted","type":"text","marks":[{"type":"code_inline"}]},{"text":" and let ffmpeg mux the audio (Remotion's AAC leaves about 2.5 f of priming). Use the ANGLE GL renderer, JPEG q95 intermediates and a 120 s ","type":"text"},{"text":"delayRender","type":"text","marks":[{"type":"code_inline"}]},{"text":" timeout; the starter's ","type":"text"},{"text":"remotion.config.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" sets all of these.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Browser.","type":"text","marks":[{"type":"strong"}]},{"text":" In a sandbox, set ","type":"text"},{"text":"REMOTION_BROWSER","type":"text","marks":[{"type":"code_inline"}]},{"text":" to the local headless Chromium if auto-detection misses it.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"HyperFrames","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"references/hyperframes-engine.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Timeline.","type":"text","marks":[{"type":"strong"}]},{"text":" Each composition has one paused timeline, registered last, with a key equal to its ","type":"text"},{"text":"data-composition-id","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Tweens","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"fromTo","type":"text","marks":[{"type":"code_inline"}]},{"text":", never ","type":"text"},{"text":"from","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never combine a CSS transform with a GSAP transform on one node.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Markup","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No ","type":"text"},{"text":"repeat:-1","type":"text","marks":[{"type":"code_inline"}]},{"text":". No ","type":"text"},{"text":"\u003cbr>","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every ","type":"text"},{"text":"\u003caudio>","type":"text","marks":[{"type":"code_inline"}]},{"text":" has an ","type":"text"},{"text":"id","type":"text","marks":[{"type":"code_inline"}]},{"text":"; one without is silently dropped from the mix.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A sub-composition's ","type":"text"},{"text":"\u003cstyle>","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"\u003cscript>","type":"text","marks":[{"type":"code_inline"}]},{"text":" go inside its ","type":"text"},{"text":"\u003ctemplate>","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fonts","type":"text","marks":[{"type":"strong"}]},{"text":" are local woff2 files declared with ","type":"text"},{"text":"@font-face","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Lint.","type":"text","marks":[{"type":"strong"}]},{"text":" Lint must show zero errors before ","type":"text"},{"text":"check","type":"text","marks":[{"type":"code_inline"}]},{"text":" means anything, because otherwise it reports a misleadingly clean \"0 samples\".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Finish.","type":"text","marks":[{"type":"strong"}]},{"text":" Finish with ","type":"text"},{"text":"bash scripts/hf-finish.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":". Films faster than about 18 px/f belong in Remotion.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"8. Review loop and ship gate","type":"text"}]},{"type":"paragraph","content":[{"text":"The full procedure, the critic prompts, the rubric anchors and the table of thresholds are in ","type":"text"},{"text":"references/review-loop.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". A round goes like this:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Render.","type":"text","marks":[{"type":"strong"}]},{"text":" Render the preview (or the affected acts).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Measure.","type":"text","marks":[{"type":"strong"}]},{"text":" Run the scripts. Each writes JSON to ","type":"text"},{"text":"out/qa/","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"python3 scripts/probe.py out/preview.mp4 --spec 1920x1080@60 --dur \u003cs>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"python3 scripts/forensics.py out/preview.mp4 --cues out/cues.json --json out/qa/forensics.json","type":"text","marks":[{"type":"code_inline"}]},{"text":". It checks pops, stalls, dead holds, ghost frames, border slivers, judder, sharpness steps, banding and compression smear on flat fields. Declare designed cuts and freezes with ","type":"text"},{"text":"--cuts","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"--freeze-ok","type":"text","marks":[{"type":"code_inline"}]},{"text":" so they are not flagged, and add ","type":"text"},{"text":"--loop","type":"text","marks":[{"type":"code_inline"}]},{"text":" for loops to check the seam.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"python3 scripts/av-audit.py out/preview.mp4 --cues out/cues.json --stems out/stems --json out/qa/av.json","type":"text","marks":[{"type":"code_inline"}]},{"text":". It checks onsets against cues (per stem, so the bed cannot hide a late effect), loudness, true peak, clicks and the tail.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sheets.","type":"text","marks":[{"type":"strong"}]},{"text":" Make contact sheets with ","type":"text"},{"text":"python3 scripts/sheet.py out/preview.mp4 --chunks 4 --rate 12 --out out/qa/sheet.png","type":"text","marks":[{"type":"code_inline"}]},{"text":", plus a legibility sheet at ","type":"text"},{"text":"--width 480","type":"text","marks":[{"type":"code_inline"}]},{"text":". Grab exact frames with ","type":"text"},{"text":"bash scripts/grab.sh out/preview.mp4 \u003cframe>…","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Lenses.","type":"text","marks":[{"type":"strong"}]},{"text":" Run the critic lenses from ","type":"text"},{"text":"assets/critics/","type":"text","marks":[{"type":"code_inline"}]},{"text":". Launch them as parallel subagents if the harness allows it; otherwise run them one after another, and look at the sheet ","type":"text"},{"text":"before","type":"text","marks":[{"type":"em"}]},{"text":" you open the code. On a tight usage budget, run the director and art lenses only, yourself, on the sheets, and let the scripts cover forensics and sync; never skip the lenses entirely, because they catch most of what the scripts can't.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Director","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"director.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"): the weakest 3 s; anything cheap, templated or dead; whether each act lands in under 1 s; whether the story reads muted; the 2 s hook; the text flow (words per frame, one fragment at a time, text and picture taking turns); the end. At most 12 changes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Forensics","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"forensics.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"): reads the script JSON and inspects the flagged frames at full size.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sound/sync","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"sound-sync.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"): onsets against cues, masking, key, the ending.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Round 1 only, art/copy/UI","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"art-copy-ui.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"): type, colour discipline, strings, data truth.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Output format.","type":"text","marks":[{"type":"strong"}]},{"text":" Each lens returns ","type":"text"},{"text":"{issues:[{id, priority, frame, problem, fix:{file, change}}], overall, score}","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"verifier.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Try to refute each P0 and P1 from frames and code. Drop an issue if it is wrong, invisible at normal speed, or if its fix would make things worse.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fix and re-render.","type":"text","marks":[{"type":"strong"}]},{"text":" Fix in order of impact per minute. Re-render only the affected acts (per-act compositions) and splice at a static seam frame.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Critic prompts","type":"text","marks":[{"type":"strong"}]},{"text":" say what changed, so it gets checked hardest. They also say \"Do not re-report fixed issues\", \"Don't propose adding text\" and \"Do propose splitting a sentence into a chain\", and they batch images into sheets, because images are expensive in context. When one act stays weak after two rounds, run the design-off in ","type":"text"},{"text":"review-loop.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Severity:","type":"text","marks":[{"type":"strong"}]},{"text":" P0 is visible on a key beat (hook, payoff, logo) or breaks the story; P1 is noticeable on a normal viewing; P2 is visible only when paused.","type":"text"}]},{"type":"paragraph","content":[{"text":"Ship gate","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The 11-dimension rubric: every dimension ≥ 3, Finish and Sync = 5, mean ≥ 4.2.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No open P0.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every script passes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stop after 5 rounds, or sooner once the gate is met. Then say plainly what you would still change.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"9. File index","type":"text"}]},{"type":"paragraph","content":[{"text":"Read each file when its step comes up. Don't read them all at once.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"When","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"File","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What it gives you","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 0, choosing a format","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/formats.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"a recipe per format: act templates, loops, stings, walkthroughs, social re-layouts","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Before the first film","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/worked-example.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tessel from idea to master, with 15 mistakes and their fixes","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/concept-and-story.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"concept lenses, ownership and deletion tests, device design, arcs, end cards","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"assets/TREATMENT.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the treatment template","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 1, after the draw","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/technique-library.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"every technique in the library: recipe, timing at 60 fps, when to use and avoid it","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Steps 1, 3–4 and review","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/craft-rules.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"measured norms for pacing, smoothness, text timing, transitions, camera, UI and colour over time","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Steps 1–2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/copy-and-type.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"copy budget, fragment chains with timing and before/after examples, callback copy, loading a family, type scale, kinetic type, safe reveals","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/brand-and-color.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"personality → choices, naming, the mark sheet, lockup proportions, tokens, accent, stages","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 2 and review","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/taste-and-slop.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"every cheap-looking tell, with its fix","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/timing-grid.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BPM/fps table, the ","type":"text"},{"text":"timeline.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" pattern with ","type":"text"},{"text":"chain()","type":"text","marks":[{"type":"code_inline"}]},{"text":", sync helpers, schedules, holds and the two density profiles","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/motion-tokens.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"easing and spring tables, the ","type":"text"},{"text":"PACE","type":"text","marks":[{"type":"code_inline"}]},{"text":" knob, ","type":"text"},{"text":"hitPulse","type":"text","marks":[{"type":"code_inline"}]},{"text":", anticipation, landings, staggers","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/camera.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"anchor camera, log-space zoom, breath/dip modifiers, rack focus, 3D limits","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/transitions.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"energy table and the seam catalogue, each with frames, curves and failure modes","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/product-ui.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the smart part, data and story asserts, FLIP inserts, counters, cursor physics, typing","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 4, Remotion","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/remotion-engine.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"verified API facts, config, CLI recipes, pitfalls","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 4, HyperFrames","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/hyperframes-engine.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"render model, composition rules, CLI, translating cinetic to GSAP","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Any render artifact","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/chromium-rendering.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"symptom → cause → fix → detection for rasterisation traps","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 5","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/sound.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"where the sounds come from (the earshot kit, its roles, the fallback), ","type":"text"},{"text":"score.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" schema, placement, scoring text, mix, master and listening","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 6","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/finishing.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"motion-blur pipeline and its cost, banding, BT.709, encoding, mux, deliverables","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 7","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/review-loop.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"round procedure, rubric, severity, thresholds, design-off","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Tools you already have.","type":"text","marks":[{"type":"strong"}]},{"text":" Reach for these before writing a helper of your own.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Need","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tool","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"a project from the starter","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/new-film.sh","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"a random, weighted draw of techniques for the film; one entry in full","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/pick.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"--show \u003cid>","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--list","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"static bans and hard bans, a leftover placeholder brand","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/lint-film.mjs","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"grid, holds, gaps, word budget, text flow (fragments, words per frame, lines arriving together, reveal and word rate, picture beats and their cues, quiet reads, text share); text in the safe zone (feed presets: ","type":"text"},{"text":"--safe feed9x16","type":"text","marks":[{"type":"code_inline"}]},{"text":"), text covered by movers, readable text too fast (","type":"text"},{"text":"--cues","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/grid-check.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"scripts/layout-audit.sh","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"a font family, vendored into the project without ","type":"text"},{"text":"npm install","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/add-font.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or ","type":"text"},{"text":"new-film.sh --font","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"a palette from one accent, contrast, accent coverage on a still","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/palette.py","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gradients that don't band","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/dither-gradient.py","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cues for the sound; score, SFX, mix, master and tail fade","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/export-cues.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"scripts/audio/score.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"synth.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"master.py","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the sound kit: init from a preset, tune to the key, render (cached), vendor, check, audition","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/audio/kit.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" (earshot)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"earshot's ears on the soundtrack: cue sync, sound sheet, critique (or on each kit role, ","type":"text"},{"text":"--kit","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/audio/listen.py","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"previews, masters, motion blur, splices, chunked renders","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/render.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"--preview","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--blur","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--frames","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--budget","type":"text","marks":[{"type":"code_inline"}]},{"text":"), ","type":"text"},{"text":"scripts/render-chunks.sh","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"on-screen speed, too-fast moves; float accumulation","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/measure-speed.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"scripts/accumulate.py","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"spec, A/V lag and true-peak gates","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/probe.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"scripts/check-sync.py","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"contact sheets, exact frame grabs","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/sheet.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"scripts/grab.sh","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pixel QA, loop seam; audio and sync QA","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/forensics.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"--loop","type":"text","marks":[{"type":"code_inline"}]},{"text":"); ","type":"text"},{"text":"scripts/av-audit.py","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"poster, GIF, WebM, loop seam, alpha, variants","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/deliver.sh","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"brand kit: SVG and PNG mark and lockups, favicons, avatar, X header; outlined lettering","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/brand-kit.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"scripts/outline-text.py","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the HyperFrames finish","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/hf-finish.sh","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]}]},"metadata":{"date":"2026-11-02","name":"cinetic","author":"@skillopedia","source":{"stars":965,"repo_name":"cinetic","origin_url":"https://github.com/Leonxlnx/cinetic/blob/HEAD/skills/cinetic/SKILL.md","repo_owner":"Leonxlnx","body_sha256":"4dadf39b147c8ec9a91899f2e0be6ea8c175c2095edea960ecab3af696e8370b","cluster_key":"ca158866deab937edd4a27c460bb9642c9d094b36071bef4610ef3bde46cdf4e","clean_bundle":{"format":"clean-skill-bundle-v1","source":"Leonxlnx/cinetic/skills/cinetic/SKILL.md","text_bytes":1048356,"source_commit":"ecfa6605de1adb9345ce937724810dc32267d068","skills_sh_rank":10778,"attachment_count":43,"text_attachments":43,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/cinetic/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"media-content","category_label":"Media"},"exact_dupes_collapsed_into_this":0},"license":"MIT","version":"v1","category":"media-content","import_tag":"skills-sh-top20k-2026-11","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.","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)."}},"renderedAt":1793661410800}

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. -…