# cinecode

Adopt this skill: https://proskills.md/skills/other/sarpstar-cinecode

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

## File: SKILL.md
size: 32786
sha: 91093176a55205627484c443827fc46a2dd62089
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/SKILL.md
truncated: true

```text
---
name: cinecode
description: >-
  Makes a short film, animation, ad or music video entirely with code, together with the user. Two first questions - the mode (Quick - one HTML file with Web Audio sound, a few questions, a low-res draft, two revisions on drafts, then the final, up to about 3 minutes; Film - six gated steps with a style frame, animatic, three.js render and layered sound) and the purpose (cinematic film, or ad & product). Also: a film on the user's own song, a film like a reference video with changes, the user's own footage cut. Ends in an MP4 with a -14 LUFS master. Use when the user says "kısa film", "kodla film", "animasyon", "reklam filmi", "reklam", "ürün videosu", "tanıtım videosu", "müzik videosu", "klip", "şarkıma video", "bunun gibi bir video", "aynı formatta", "hızlı mod", "film modu", "make a short film", "animated short", "product video", "promo video", "music video", "explainer video", "cinecode", or asks what it can make, or when the folder has a STATE.md made by this kit and no FILM-BIBLE.md.
hooks:
  UserPromptSubmit:
    - hooks:
        - type: command
          command: node
          args:
            - -e
            - "const f=require('fs'),p=require('path'),e=process.env,u=p.join(require('os').homedir(),'.claude','skills'),w=p.join(e.CLAUDE_PROJECT_DIR||process.cwd(),'.claude','skills');let d=[e.CLAUDE_SKILL_DIR,p.join(u,'cinecode'),p.join(w,'cinecode')];for(const s of [u,w]){try{d=d.concat(f.readdirSync(s).map(n=>p.join(s,n)))}catch(x){}}const r=d.find(x=>x&&f.existsSync(p.join(x,'scripts','hook.cjs'))&&f.existsSync(p.join(x,'scripts','guard.py')));if(r)require(p.join(r,'scripts','hook.cjs'))(process.argv[1])"
            - prompt
          onFailure: continue
  Stop:
    - hooks:
        - type: command
          command: node
          args:
            - -e
            - "const f=require('fs'),p=require('path'),e=process.env,u=p.join(require('os').homedir(),'.claude','skills'),w=p.join(e.CLAUDE_PROJECT_DIR||process.cwd(),'.claude','skills');let d=[e.CLAUDE_SKILL_DIR,p.join(u,'cinecode'),p.join(w,'cinecode')];for(const s of [u,w]){try{d=d.concat(f.readdirSync(s).map(n=>p.join(s,n)))}catch(x){}}const r=d.find(x=>x&&f.existsSync(p.join(x,'scripts','hook.cjs'))&&f.existsSync(p.join(x,'scripts','guard.py')));if(r)require(p.join(r,'scripts','hook.cjs'))(process.argv[1])"
            - stop
          onFailure: continue
  PreToolUse:
    - matcher: "*"
      hooks:
        - type: command
          command: node
          args:
            - -e
            - "const f=require('fs'),p=require('path'),e=process.env,u=p.join(require('os').homedir(),'.claude','skills'),w=p.join(e.CLAUDE_PROJECT_DIR||process.cwd(),'.claude','skills');let d=[e.CLAUDE_SKILL_DIR,p.join(u,'cinecode'),p.join(w,'cinecode')];for(const s of [u,w]){try{d=d.concat(f.readdirSync(s).map(n=>p.join(s,n)))}catch(x){}}const r=d.find(x=>x&&f.existsSync(p.join(x,'scripts','hook.cjs'))&&f.existsSync(p.join(x,'scripts','guard.py')));if(r)require(p.join(r,'scripts','hook.cjs'))(process.argv[1])"
            - tool
          onFailure: continue
---

# cinecode

You make a short film with the user. Their taste decides; you do the rest: story, script, picture, sound, edit, checks. The aim is a film worth the wait. You work best from a short, exact plan and your own eyes: so the plan is one card with numbers, you look at stills before anything long renders, and every note becomes a number to change.

This file is the core. Open a reference only when a step names it, and only its section. The kit's tools enforce the mechanical rules (dashes in the words, still or repeated drafts, the show-first lock, the film folder, question language, chat size): when one stops, it prints what to do; follow it rather than restating it.

Reply in the user's language; project files stay in English unless the user prefers otherwise. Messages are short: what was made, where, one or two questions, a recommendation.

## Asking the user

Use the question tool when there is one (Claude Code `AskUserQuestion`: 1–4 questions a call, 2–4 options, a header of at most 12 characters; Cursor `AskQuestion`); otherwise one numbered message.

- **Show before you ask.** Never ask for an OK on something the user has not seen in this chat: beats and lines are in the message itself, boards, stills and drafts are shown big as below. Too long for one message: show it part by part, ask at the last.
- One question per item; the recommended option first with "★ (önerilen)" / "★ (recommended)" and its reason; the user's own answer always allowed. More candidates than slots: number them on a board, the rest are picked by typing their number; without a board, keep the best and the rest stay reachable as the own answer.
- A round over the tool's limit becomes consecutive calls; one-line notes (the sound line, defaults) go in a short message before the first call.
- **The idea stays open:** without one, ask it as a plain message first; once given, its 2–3 interpretations become the options, from three sparks (`deal.py deal spark`: each led by a seed, a real thing drawn by chance from outside, with a place, a form and a medium, so no two films start from the same place), after the prediction test and the overlap check (`writing.md` §1: fresh helpers; one writes the films the first message makes anyone expect, the other marks every candidate that repeats those or one of this user's own films, and a marked one is replaced). Invent a story only on "sen öner" / "suggest".
- A gate is one question ("OK, continue ★" · "One note" · "A swap"), the note typed in "Other".
- **The music is always the user's:** in both modes and at any pace, the first questions ask where it comes from (★ a real open-licence recording · their own track · written in code · none), never folded into another question; a recording is picked by ear (2–3 candidates sent as files, one question), even with `Pace: through`; a long film whose acts change mood may take a piece per act or part (a 12-minute film perhaps 3–4), its candidates grouped by act in that one listening question, while a short film keeps one track; music is made in code only when the user chose it. The pick goes into `STATE.md` as `Music: <recording file / own file / code / none> (user)`; `make.py` holds a draft whose music is code-made without that line.
- **The pace of production is the user's (Film mode):** asked with the script gate, when production starts, as a second question in the same call: **"Aşama aşama"** (style frame, type, animatic, draft, each its own gate) or **"Hepsini üret, taslakta göster"** (straight to the draft cut). ★ the second up to about 3 minutes; ★ the first above, where a long render is worth a look at the frame and the pacing first. Written in `STATE.md` as `Pace: steps` or `Pace: through`. With `through`, or on "artık sorma, bitir" at any point, the film runs to the draft without a question, except a real blocker (a missing key, money, a legal limit): every choice goes to the ★ and is written in `STATE.md`, the style frame and key stills reach the user as progress, never as questions, and the draft lists the choices made so any of them can be a note. "Dur, göster" brings the gates back.
- **An approved gate starts the next step in the same turn:** "Tamam", "onay", a ★ pick or "devam" is the go for what comes next, never the end of the work. A turn ends only on a question to the user, a delivery, or a background job named with how long it runs.
- **Nothing runs silently:** before a step that takes more than about 30 s with nothing to show yet (a search, a board, a render, a helper), say in one line what it is doing and how long ("Stil panosu için film kareleri arıyorum, yarım dakika").
- **A long job reports its progress:** a render or helper running over about 2 minutes gets a progress note with done/total and the minutes left (the status file's `eta_s`), again at about half way; "bitti mi?" is answered from the status file in one line, never with
```

## File: README.md
size: 44181
sha: 79170ad1923ec819fc159369fc6153ad7ed4ee9f
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/README.md

```text
<p align="center">
  <img src="docs/media/lockup.png" alt="cinecode" width="560">
</p>

<p align="center">
  <b>Kodla yazılmış filmler.</b><br>
  Kısa filmler, uzun YouTube videoları, reklamlar, açıklayıcılar ve müzik videoları. Fikri ve zevki sen getirirsin; bir yapay zekâ kodlama ajanı senaryoyu yazar, her kareyi kodla kurar, sesi tasarlar ve sana bitmiş bir MP4 verir.
</p>

<p align="center">
  <a href="README.en.md">English</a> ·
  <a href="README.zh-CN.md">简体中文</a> ·
  <a href="README.ja.md">日本語</a> ·
  <a href="#kurulum">Kurulum</a> ·
  <a href="#hızlı-başlangıç">Hızlı başlangıç</a> ·
  <a href="#sss">SSS</a>
</p>

<p align="center">
  <a href="https://github.com/sarpstar/cinecode/actions/workflows/tests.yml"><img src="https://img.shields.io/github/actions/workflow/status/sarpstar/cinecode/tests.yml?branch=main&label=tests&style=flat-square" alt="tests"></a>
  <img src="https://img.shields.io/badge/license-MIT-C37232?style=flat-square" alt="license: MIT">
  <img src="https://img.shields.io/badge/agents-Claude%20Code%20%C2%B7%20Cursor-1a1a1a?style=flat-square" alt="agents: Claude Code, Cursor">
  <img src="https://img.shields.io/badge/render-three.js%20%C2%B7%20ffmpeg-1a1a1a?style=flat-square" alt="render: three.js, ffmpeg">
</p>

<p align="center">
  <img src="docs/media/reel.gif" alt="cinecode ile yapılmış filmlerden 30 saniye: gün doğumunda taş kemerler, karda linol baskı bir posta treni, oyulmuş bir saat mekanizması, çölde 3B bir tavşan, yağan kodun altında minyatür bir şehir, su altında pop-art bir balina, turuncu kapılardan geçen bir yol, üç dikey açıklayıcı, buğday tarlasında raylı bir film kamerası, altın ışık sütunları altında köpekler, bir kibrit, bir fırın ve bir caz afişi, fenerli bir çocuğun orman masalı, gece ufukta bir gemi" width="100%">
</p>
<p align="center"><sub>cinecode ile yapılmış filmlerden 30 saniye. Her kare başsız Chrome'da koddan üretildi.</sub></p>

Kendi bilgisayarında, **Claude Code** (masaüstü uygulaması, VS Code eklentisi, terminal) ya da **Cursor** içinde çalışır. Ücretli bir servis gerekmez; dış ses, yalnızca istersen, kendi Gemini anahtarını (ücretsiz kotası var), ElevenLabs'i ya da ücretsiz bir Türkçe sesi kullanır.

## Neler yapabilirsin

### Kodla yapılmış kısa filmler

Aklındakini tek cümleyle yaz; gerisini birlikte kurarsınız.

- **Sıradan fikirde kalmaz.** Ajan fikrini, dünyadan rastgele çekilen gerçek bir şeyle buluşturur: bir müze eseri, bir hayvan, uzaydan bir fotoğraf, eski bir icat, tarihî bir yer, bir zanaat. Böylece sana herkesin aklına ilk gelen filmi değil, fikrinin senin bile düşünmediğin yorumlarını getirir.
- **Kendini tekrar etmez.** Her öneri senin daha önce yaptığın filmlerle karşılaştırılır; aynı filmi sana ikinci kez önermez.
- **Son söz hep senin.** Filmin neyden yapılacağını sen seçersin: gerçek ışıklı 3B, 2B animasyon, boya, mürekkep, kâğıt, kil, hareketli grafik ya da piksel. Görünümü gerçek filmlerden karelerle kurulmuş bir panodan seçersin; film bütünüyle kurulmadan önce ilk iki sahneyi görüp onaylarsın.

### Uzun YouTube videoları

On dakikayı aşan belgeseller, açıklayıcılar ve video denemeleri; Film modunda perdeler ve bölümler hâlinde kurulur:

- gerçek bir kişi ya da olay hakkındaki her iddianın kaynağıyla yazıldığı, araştırılmış bir metin;
- ilk dakikada kurulan açık bir soru ve izleyiciyi sona kadar tutan bir merak haritası;
- senin dilinde bir anlatıcı (Türkçe ve İngilizce sesler, ücretsiz Türkçe sesler dahil), çiğ motor sesiyle değil, filme ait bir ses işlemesiyle;
- açık arşivlerden fotoğraflar, haritalar, belgeler ve görüntüler; resmi olmayan yerde kod: kendini çizen haritalar, sayaçlar, şemalar, yeniden kurulan mekânlar;
- biçim isterse bölüm kartları, her perde için kulakla seçilen müzik, −14 LUFS mastering ve video açıklaman için bir künye dosyası.

### Reklam ve ürün filmleri

Ürünün, renklerin, fontların, logon ve çağrı cümlen. Ürün ayrıntısıyla kurulur ve stüdyo çekimi gibi aydınlatılır; yazılım için arayüz kahraman planları; özgüllük testi filmin başka bir markaya uymasını önler; 9:16, 1:1 ve 16:9 sürümleri güvenli alanlarıyla gelir.

### Sosyal medya için açıklayıcılar

30–60 saniyelik dikey filmler: ilk saniyede bir soru, açık lisanslı ve Türkçesi denenmiş bir font eşleşmesiyle kinetik yazı, çizgi çizgi kurulan şemalar, telefonda okunan sözcükler.

### Müzik videoları

Şarkını getir: vuruş ızgarası ölçülür, kesmeler ölçülere, vurgular vuruşlara oturur, her söz satırına bir görüntü.

### Ve dahası

- **Sevdiğin bir video gibi ama farklı:** biçimi ölçülür (kesmeler, tempo, ışık, renk), film o biçimle yeniden yapılır, onun karelerinden asla.
- **Kendi çekimlerinin kurgusu:** konuşan kafa ya da ekran kaydı, kelime zamanlı bir dökümden kesilir; duraklamalar kırpılır, grafikler sözcüklere oturur.
- **Tek formatta seri:** bir filmin formatını kaydedip sonraki bölümü o formatta yaparsın.

15 saniyelik bir klipten on dakikayı aşan filmlere kadar. Hızlı mod yaklaşık 3 dakikaya kadar bir filmi tek oturumda yapar; Film modu daha uzun filmleri onaylı adımlarla götürür. Film uzadıkça daha çok zaman ve kullanım ister.

## cinecode ile yapılanlar

<table>
  <tr>
    <td width="33%"><img src="docs/media/stills/motor.jpg" alt="Motor: gün batımında kırılırken donmuş bir dalga"><br><sub><b>Motor</b> · donmuş anlar · 90 sn</sub></td>
    <td width="33%"><img src="docs/media/stills/tren.jpg" alt="The Mail Train: linol baskı bir kar manzarasında kırmızı posta vagonu"><br><sub><b>The Mail Train</b> · linol baskı bir yolculuk · 25 sn</sub></td>
    <td width="33%"><img src="docs/media/stills/ilktik.jpg" alt="İlk Tik: altın bir saat mekanizmasına oyulmuş şehir silueti"><br><sub><b>İlk Tik</b> · bir saat reklamı · 50 sn</sub></td>
  </tr>
  <tr>
    <td><img src="docs/media/stills/atlama.jpg" alt="Atlama Programı: yağan yeşil kodun altında kâğıttan bir şehir"><br><sub><b>Atlama Programı</b> · minyatür bir saygı duruşu · 50 sn</sub></td>
    <td><img src="docs/media/stills/koltuk.jpg" alt="Boş Koltuk: boş bir tarlada raylı film kamerası"><br><sub><b>Boş Koltuk</b> · kendini kuran bir film seti · 80 sn</sub></td>
    <td><img src="docs/media/stills/kopek.jpg" alt="Yüz Köpek: ışık sütunları altında nokta nokta köpekler"><br><sub><b>Yüz Köpek</b> · bir veri belgeseli · 90 sn</sub></td>
  </tr>
  <tr>
    <td><img src="docs/media/stills/titanik.jpg" alt="Titanik, Son Saatler: karanlık denizde batan gemi"><br><sub><b>Titanik, Son Saatler</b> · bir belgesel · 5 dk</sub></td>
    <td><img src="docs/media/stills/cinecode.jpg" alt="cinecode: renkli pencerelerden pop-art bir kule"><br><sub><b>cinecode</b> · pop-art bir tanıtım · 60 sn</sub></td>
    <td><img src="docs/media/stills/fener.jpg" alt="Fener: ay ışığında bir kız ve bir ağaç ruhu"><br><sub><b>Fener</b> · animasyon bir masal · 60 sn</sub></td>
  </tr>
  <tr>
    <td><img src="docs/media/stills/gokyuzu.jpg" alt="Gökyüzü neden mavi?: ışığı ayıran bir prizma ve büyük sarı yazı"><br><sub><b>Gökyüzü neden mavi?</b> · bir açıklayıcı · 45 sn, 9:16</sub></td>
    <td><img src="docs/media/stills/firin.jpg" alt="Kuzguncuk Fırın: lambanın altında fırından yeni çıkmış simitler"><br><sub><b>Kuzguncuk Fırın</b> · bir fırın reklamı · 15 sn, 9:16</sub></td>
    <td><img src="docs/media/stills/kibrit.jpg" alt="Tek Kibrit: bakır renginde parlayan oyma bir şehir"><br><sub><b>Tek Kibrit</b> · grafik bir kısa film · 63 sn, 9:16</sub></td>
  </tr>
</table>

## Nasıl çalışır

1. **Fikri söylersin.** Bir cümle yeter; eksiksiz bir brief doğrudan işe koyulur.
2. **Birkaç soru sorar** (fikrin okumaları, filmin neyden yapılacağı, süre ve format, görünüm, ses ve müzik); her birinde önerilen cevap ★ ile işaretli.
3. **Planı gösterir:** saniye saniye vuruşlar ve satır satır değiştirebileceğin bir yönetmen kartı. Sonra gerisi kurulmadan önce iki sahne, kare olarak.
4. **Taslağı render eder,** denetler (ilk kareden hareket, ses yüksekliği, okunurluk, tekrarlar), sana gösterir; bağımsız bir izleyici de onu soğuk gözle izler. Notların yalnızca dokundukları yeri değiştirir.
5
```

## File: scripts/animatic.py
size: 10760
sha: 5c62f3a75d3b5f916c1806256bf60c7fa824ac84
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/animatic.py

```text
#!/usr/bin/env python3
"""Build a timed, low-resolution animatic with temp sound from shots.json (Python 3 stdlib + ffmpeg + Chrome).

  python3 tools/animatic.py shots.json --out renders/animatic-v1.mp4
  python3 tools/animatic.py shots.json --out renders/animatic-v2.mp4 --stills review/stills --clips renders/rough --vo audio/temp_vo.wav

Per shot, in this order of preference:
  --clips DIR/<id>.mp4    a rough render of the shot (scaled, trimmed or held to the shot length)
  --stills DIR/<id>.png   a still (png/jpg) drawn as a card with the shot's on-screen text and VO line
  neither                 a grey card with the shot id, timecode, description and reads
Cards are drawn on a canvas in headless Chrome by tools/capture.mjs (same folder), so text needs no ffmpeg fonts.
Temp sound: a seeded brown-noise bed and a soft tick on every cut; --vo mixes a temp narration on top.
Width defaults to 1/3 of the film width (--scale 0.333). Nothing is deleted; work files go to <out>.work/.
"""
import argparse
import base64
import hashlib
import json
import math
import os
import random
import shutil
import struct
import subprocess
import sys
import wave

HERE = os.path.dirname(os.path.abspath(__file__))

def run(cmd):
    p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    if p.returncode != 0:
        sys.exit(f"failed: {' '.join(cmd[:4])} ...\n{p.stderr[-2000:]}{p.stdout[-1000:]}")
    return p.stdout

def find(dir_, sid, exts):
    if not dir_:
        return None
    for e in exts:
        p = os.path.join(dir_, sid + e)
        if os.path.exists(p):
            return p
    return None

def still_data_url(path, w, work):
    small = os.path.join(work, "still-" + os.path.basename(path) + ".jpg")
    run(["ffmpeg", "-v", "error", "-y", "-i", path, "-vf", f"scale={w}:-2", "-q:v", "4", "-frames:v", "1", small])
    return "data:image/jpeg;base64," + base64.b64encode(open(small, "rb").read()).decode()

CARDS_HTML = """<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;background:#000}</style></head>
<body><canvas id="c"></canvas><script>
const CARDS = %s, W = %d, H = %d, FPS = %s;
const c = document.getElementById('c'); c.width = W; c.height = H; const g = c.getContext('2d');
const imgs = CARDS.map((k) => { if (!k.img) return null; const i = new Image(); i.src = k.img; return i; });
function wrap(text, x, y, maxW, lh, maxLines) {
  const words = String(text || '').split(/\\s+/); let line = '', n = 0;
  for (const w of words) { const t = line ? line + ' ' + w : w;
    if (g.measureText(t).width > maxW && line) { g.fillText(line, x, y); y += lh; line = w; if (++n >= maxLines - 1) break; } else line = t; }
  if (line) g.fillText(line, x, y); return y + lh;
}
window.renderFrame = (i) => {
  const k = CARDS[i], u = Math.min(W, H) / 100;
  g.fillStyle = '#2a2d33'; g.fillRect(0, 0, W, H);
  const im = imgs[i];
  if (im) { const s = Math.max(W / im.width, H / im.height); g.drawImage(im, (W - im.width * s) / 2, (H - im.height * s) / 2, im.width * s, im.height * s); }
  g.textBaseline = 'top'; g.fillStyle = 'rgba(0,0,0,0.55)'; g.fillRect(0, 0, W, u * 9);
  g.fillStyle = '#ffd36b'; g.font = `bold ${u * 4}px sans-serif`; g.fillText(`${k.id}  ${k.tc}  (${k.dur.toFixed(1)} s)`, u * 3, u * 2.5);
  if (!im) { g.fillStyle = '#e8e8e8'; g.font = `${u * 4.2}px sans-serif`; let y = wrap(k.desc, u * 5, H * 0.3, W - u * 10, u * 5.6, 6);
    g.fillStyle = '#9fb4c8'; g.font = `${u * 3.4}px sans-serif`; for (const r of k.reads) y = wrap('- ' + r, u * 5, y + u, W - u * 10, u * 4.4, 2); }
  k.text.forEach((t, j) => { g.fillStyle = '#ffffff'; g.font = `bold ${u * 6}px sans-serif`; g.textAlign = 'center';
    g.fillText(t, W / 2, H * 0.42 + j * u * 7.5); g.textAlign = 'left'; });   // several cards stack, never overlap
  if (k.vo) { g.fillStyle = 'rgba(0,0,0,0.6)'; g.fillRect(0, H - u * 16, W, u * 16); g.fillStyle = '#ffffff';
    g.font = `italic ${u * 3.8}px sans-serif`; g.textAlign = 'center'; wrap('VO: ' + k.vo, W / 2, H - u * 13, W - u * 8, u * 4.8, 2); g.textAlign = 'left'; }
};
window.captureFrame = (i, type = 'image/png', q = 0.95) => { window.renderFrame(i); return c.toDataURL(type, q); };
window.READY = Promise.all(imgs.filter(Boolean).map((i) => i.decode())).then(() => ({ frames: CARDS.length, fps: FPS, width: W, height: H, renderer: '2d canvas' }));
</script></body></html>
"""

def tc(frames, fps):
    s = frames / fps
    return f"{int(s // 60)}:{s % 60:04.1f}"

def temp_sound(path, total_s, cuts_s, seed, sr=48000):
    rng = random.Random(seed)
    n = int(total_s * sr)
    brown, out = 0.0, bytearray()
    ticks = [int(c * sr) for c in cuts_s]
    tick_len = int(0.03 * sr)
    for i in range(n):
        brown = 0.995 * brown + 0.02 * (rng.random() * 2 - 1)       # leaky brown noise: a soft room bed
        v = brown * 0.9
        for t0 in ticks:
            d = i - t0
            if 0 <= d < tick_len:
                v += 0.25 * math.sin(2 * math.pi * 1800 * d / sr) * math.exp(-d / (0.006 * sr))
        fade = min(1.0, i / (0.05 * sr), (n - i) / (0.3 * sr))
        out += struct.pack("<h", int(max(-1.0, min(1.0, v * fade)) * 32767 * 0.5))
    with wave.open(path, "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(sr)
        w.writeframes(bytes(out))

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("shots")
    ap.add_argument("--out", required=True)
    ap.add_argument("--stills")
    ap.add_argument("--clips")
    ap.add_argument("--vo", help="temp narration WAV, placed at 0 s (pre-timed)")
    ap.add_argument("--scale", type=float, default=0.333)
    a = ap.parse_args()

    film = json.load(open(a.shots, encoding="utf-8"))
    fps = float(film.get("fps", 24))
    W = max(160, int(film["width"] * a.scale) // 2 * 2)
    H = max(160, int(film["height"] * a.scale) // 2 * 2)
    work = a.out + ".work"
    os.makedirs(work, exist_ok=True)
    os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
    if os.path.exists(a.out):
        sys.exit(f"{a.out} exists; animatics are versioned, choose a new name")

    shots = film["shots"]
    cards = []
    for s in shots:
        still = find(a.stills, s["id"], (".png", ".jpg", ".jpeg"))
        cards.append({"id": s["id"], "tc": tc(s["start"], fps), "dur": (s["end"] - s["start"]) / fps,
                      "desc": s.get("description", ""), "reads": [r.get("read", "") for r in s.get("reads", [])],
                      "text": [t.get("text", "") for t in s.get("text_on_screen", [])] + [      # the film's text track
                          t.get("text", "") for t in film.get("text_on_screen") or [] if isinstance(t.get("t"), list)
                          and len(t["t"]) == 2 and t["t"][0] < s["end"] / fps and t["t"][1] > s["start"] / fps],
                      "vo": s.get("vo", ""),
                      "img": still_data_url(still, W, work) if still else None})
    html = os.path.join(work, "cards.html")
    open(html, "w", encoding="utf-8").write(CARDS_HTML % (json.dumps(cards), W, H, json.dumps(fps)))
    cards_dir = os.path.join(work, "cards")
    if os.path.isdir(cards_dir):
        shutil.rmtree(cards_dir)                               # work files only, rebuilt every run
    run(["node", os.path.join(HERE, "capture.mjs"), "--url", html, "--out", cards_dir])

    segs = []
    for i, s in enumerate(shots):
        dur = (s["end"] - s["start"]) / fps
        seg = os.path.join(work, f"seg-{i:02d}.mp4")
        clip = find(a.clips, s["id"], (".mp4", ".mov", ".webm"))
        vf = f"scale={W}:{H}:force_original_aspect_ratio=increase:out_range=tv,crop={W}:{H},fps={fps},format=yuv420p"
        if clip:
            run(["ffmpeg", "-v", "error", "-y", "-i", clip, "-vf", vf + f",tpad=stop_mode=clone:stop_duration={dur}",
                 "-t", f"{dur}", "-an", "-c:v", "libx264", "-crf", "26", seg])
        else:
            run(["ffmpeg", "-v", "error", "-y", "-loop", "1", "-i", os.path.join(cards_dir, f"{i:06d}.png"),
                 "-vf", vf, "-t", f"{dur}", "-an", "-c:v", "libx264", "-crf", "26", seg])
        segs.append(seg)
    lst = os.path.join(work, "segments.txt")
    q = lambda p: os.path.abspath(p).replace("'", "'\\''")      # concat lists quote paths; a ' in a folder name is escaped
    with open(lst, "w", encoding="utf-8") as fh:
        fh.write("".join(f"file '{q(p)}'\n" for p in segs))
    picture = os.path.join(work, "picture.mp4")
    run(["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", lst, "-c", "copy", picture])

    total = shots[-1]["end"] / fps
    cuts = [s["start"] / fps for s in shots[1:]]
    bed = os.path.join(work, "temp_sound.wav")
    temp_sound(bed, total, cuts, film.get("seed", 7))
    if a.vo and not os.path.exists(a.vo):
        print(f"no temp narration at {a.vo}: the animatic keeps the temp bed only")
        a.vo = None
    if a.vo:
        run(["ffmpeg", "-v", "error", "-y", "-i", picture, "-i", bed, "-i", a.vo, "-filter_complex",
             "[1]volume=0.6[b];[2]aresample=48000[v];[b][v]amix=inputs=2:normalize=0:duration=first[a]",
             "-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", a.out])
    else:
        run(["ffmpeg", "-v", "error", "-y", "-i", picture, "-i", bed, "-map", "0:v", "-map", "1:a", "-c:v", "copy",
             "-c:a", "aac", "-b:a", "160k", "-shortest", a.out])
    record = os.path.join(os.path.dirname(os.path.abspath(a.out)), ".animatics.json")   # make.py check reads it
    try:
        with open(record, encoding="utf-8") as fh:
            made = json.load(fh)
    except (OSError, ValueError):
        made = {}
    with open(a.out, "rb") as fh:
        made[os.path.basename(a.out)] = hashlib.sha1(fh.read()).hexdigest()
    with open(record, "w", encoding="utf-8") as fh:
        json.dump(made, fh, indent=1)
    n_clips = sum(1 for s in shots if find(a.clips, s["id"], (".mp4", ".mov", ".webm")))
    n_stills = sum(1 for s, c in zip(shots, cards) if c["img"] and not find(a.clips, s["id"], (".mp4", ".mov", ".webm")))
    print(f"animatic: {a.out} ({W}x{H}, {len(shots)} shots, {total:.2f} s; "
          f"rough clips {n_clips}, stills {n_stills}, grey cards {len(shots) - n_clips - n_stills})")

if __name__ == "__main__":
    if os.name == "nt" and not sys.flags.utf8_mode:   # Windows reads, writes and prints in a legacy code page
        import subprocess
        sys.exit(subprocess.call([sys.executable] + sys.argv, env=dict(os.environ, PYTHONUTF8="1")))
    for _s in (sys.stdin, sys.stdout, sys.stderr):
        getattr(_s, "reconfigure", lambda **k: None)(encoding="utf-8", errors="replace")
    main()
```

## File: scripts/asset_search.py
size: 56314
sha: 7ddfd189a3d359906dc2d38c0d7577151340b04b
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/asset_search.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/asset_search.py

## File: scripts/beat_grid.py
size: 9757
sha: 540af53ee409dbe5a0217bf634ea3f00b75edd48
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/beat_grid.py

```text
#!/usr/bin/env python3
"""The beat grid of the user's own song or track: tempo, beat phase, downbeats (bars) and where each lyric line lands
(Python 3 standard library + ffmpeg; no installs).

  python3 tools/beat_grid.py song.mp3                          # BPM, half-time, first beat, first downbeat, bars
  python3 tools/beat_grid.py song.mp3 --from 42 --to 102       # only that part of the song
  python3 tools/beat_grid.py song.mp3 --lrc song.lrc           # + each lyric line's beat and bar
  python3 tools/beat_grid.py song.mp3 --json audio/beats.json  # everything as JSON (bars, lines) for the cue table

The tempo is measured on the whole song (up to 6 minutes) even when --from/--to pick a part; the part only sets the
phase and the downbeat, and a note is printed when the part alone reads differently (an intro in a dotted figure).

Method: an onset envelope from the change in short-time energy (log, rectified, local mean removed), the tempo from
its autocorrelation between 70 and 170 BPM (interpolated lag), the beat phase from the comb of beats that collects
the most onset energy, and the downbeat from the low band (under 150 Hz: kick and bass) summed per beat position in
a bar of four. The pulse strength (how far the tempo's autocorrelation peak stands above its mean) says whether there is a beat
at all: noise, wind and drones measure about 1.1x, songs 3x and more, and audio without any sharp onset (a held
tone, silence) reads 0; under 2x the grid is not to be cut to. Songs in 3/4 or with a soft low end can get the downbeat wrong: check the printed bar times by ear
or against the lyric lines (lines usually start a fraction of a beat before a bar). For a ballad the half-time
pulse often reads better as BPM in film.html. Method after the beat grid of tuzhechen2005/opus-video-skills (MIT).

Then cut a clip that starts on a downbeat, so film time 0 is a bar line (the command is printed), set BPM and
BEAT0 = 0 in film.html, and put cues on beat(n) / bar(n) (references/music-video.md).
"""
import argparse
import array
import itertools
import json
import math
import os
import re
import subprocess
import sys

SR, HOP, WIN = 11025, 128, 512
FPS = SR / HOP
LAT = (WIN - HOP / 2) / SR                 # an onset shows when it enters the window's end: shift times back

def envelope(path, t0, t1, band):
    cmd = ["ffmpeg", "-v", "error", "-ss", str(t0)] + (["-t", str(t1 - t0)] if t1 else []) + \
          ["-i", path, "-ac", "1", "-ar", str(SR), "-af", band, "-f", "s16le", "-"]
    raw = subprocess.run(cmd, capture_output=True, check=True).stdout
    x = array.array("h")
    x.frombytes(raw[: len(raw) // 2 * 2])
    if sys.byteorder == "big":
        x.byteswap()
    pre = list(itertools.accumulate((v * v for v in x), initial=0))
    n = (len(x) - WIN) // HOP
    if n < FPS * 4:
        raise SystemExit("too short: give at least 4 seconds of music")
    loge = [math.log(1e-3 + (pre[i * HOP + WIN] - pre[i * HOP]) / WIN) for i in range(n)]
    flux = [0.0] + [max(0.0, loge[i] - loge[i - 1]) for i in range(1, n)]
    k = 32                                              # remove the local mean (about 0.4 s each side)
    pf = list(itertools.accumulate(flux, initial=0))
    return [max(0.0, flux[i] - (pf[min(n, i + k)] - pf[max(0, i - k)]) / (min(n, i + k) - max(0, i - k))) for i in range(n)]

def tempo(flux):
    lo, hi = int(FPS * 60 / 170) - 1, int(FPS * 60 / 70) + 2
    n = len(flux)
    ac = {l: sum(flux[i] * flux[i + l] for i in range(n - l)) / (n - l) for l in range(lo, hi + 1)}

    def score(bpm):
        lag = FPS * 60 / bpm
        l, f = int(lag), lag - int(lag)
        return ac[l] * (1 - f) + ac[l + 1] * f
    best = max((70 + 0.05 * i for i in range(2001)), key=score)
    mean = sum(ac.values()) / len(ac)
    return best, (max(ac.values()) / mean if mean > 0 else 0.0)   # tempo, and how far its peak stands out (pulse)

def comb(flux, beat, p):
    n, s, t = len(flux), 0.0, p
    while t * FPS < n - 1:
        i = int(round(t * FPS))
        s += max(flux[max(0, i - 1): i + 2])
        t += beat
    return s

def refine(flux, bpm):
    """Tempo and phase together: the autocorrelation lands within a lag step; the comb over the whole clip settles
    the last fraction of a BPM (0.3 % off drifts a beat in a minute)."""
    best = (0.0, bpm, 0.0)
    for j in range(-60, 61):
        b = bpm * (1 + j * 0.0002)
        beat = 60 / b
        for i in range(int(beat / 0.01)):
            sc = comb(flux, beat, 0.01 * i)
            if sc > best[0]:
                best = (sc, b, 0.01 * i)
    _, b, p = best
    beat = 60 / b
    p = max((p + 0.002 * k for k in range(-5, 6) if p + 0.002 * k >= 0), key=lambda q: comb(flux, beat, q))
    return b, p

def phase_for(flux, beat):
    return max((0.002 * i for i in range(int(beat / 0.002))), key=lambda q: comb(flux, beat, q))

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("audio")
    ap.add_argument("--from", dest="t0", type=float, default=0.0, help="start (s) in the song")
    ap.add_argument("--to", dest="t1", type=float, default=0.0, help="end (s) in the song")
    ap.add_argument("--lrc", help="lyric timings ([mm:ss.xx] text per line)")
    ap.add_argument("--json", help="write the grid as JSON here")
    a = ap.parse_args()

    # The tempo comes from the whole song (up to 6 min): a part alone can read a dotted or triplet figure as the pulse
    # (4:3) or a half-time section as half the tempo. The phase and the downbeat come from the part itself.
    windowed = a.t0 > 0 or a.t1 > 0
    song = envelope(a.audio, 0, 360, "highpass=f=40")
    coarse, pulse = tempo(song)
    if max(song) < 0.5:                             # no onsets at all (a held tone, silence): no pulse to speak of
        pulse = 0.0
    bpm, phase = refine(song, coarse)
    full = envelope(a.audio, a.t0, a.t1 or None, "highpass=f=40") if windowed else song
    low = envelope(a.audio, a.t0, a.t1 or None, "lowpass=f=150")
    beat = 60 / bpm
    note = ""
    if windowed:
        phase = phase_for(full, beat)
        part = tempo(full)[0]
        if abs(part / bpm - 1) > 0.02:
            note = (f"note: this part alone reads as {part:.2f} BPM ({part / bpm:.2f}x the song's pulse: a dotted, "
                    f"triplet or half-time figure); the grid uses the song's {bpm:.2f}")
    n = len(full)
    peak = max(full)
    start = next((i for i, v in enumerate(full) if v > 0.1 * peak), 0) / FPS   # the music's first onset
    beats_t = []
    t = phase + beat * max(0, math.ceil((start - phase - beat / 2) / beat))
    phase = t
    while t * FPS < n - 1:
        beats_t.append(t)
        t += beat
    weight = [0.0] * 4                                    # which of four beat positions carries the low end
    for j, bt in enumerate(beats_t):
        i = int(round(bt * FPS))
        weight[j % 4] += max(low[max(0, i - 2): i + 3])
    down = max(range(4), key=lambda k: weight[k])
    first_beat = a.t0 + phase + LAT
    first_down = a.t0 + beats_t[down] + LAT
    bars = [round(a.t0 + bt + LAT, 3) for bt in beats_t[down::4]]
    conf = max(weight) / (sum(weight) / 4) if sum(weight) else 0

    print(f"bpm {bpm:.2f} (half-time {bpm / 2:.2f}) · beat {beat:.4f} s · bar {4 * beat:.3f} s · pulse {pulse:.2f}x"
          + ("" if pulse >= 2.0 else " (no clear beat: ambient, speech or a drone; do not cut to this grid)"))
    if note:
        print(note)
    print(f"first beat {first_beat:.3f} s · first downbeat {first_down:.3f} s "
          f"(low-end accent {conf:.2f}x the average; under 1.15x the downbeat is a guess)")
    print("bars: " + " ".join(f"{b:.2f}" for b in bars[:8]) + (" …" if len(bars) > 8 else ""))
    print(f"clip on a bar line: ffmpeg -ss {first_down:.3f} -t <length> -i \"{a.audio}\" "
          f"-af \"afade=t=out:st=<length-2>:d=2\" -ac 2 -ar 48000 audio/music.wav   (then BPM = {bpm:.2f}, BEAT0 = 0)")

    lines = []
    if a.lrc:
        for raw in open(a.lrc, encoding="utf-8"):
            m = re.match(r"\[(\d+):(\d+(?:\.\d+)?)\](.*)", raw.strip())
            if not m:
                continue
            t = int(m.group(1)) * 60 + float(m.group(2))
            b = round((t - first_down) / beat, 2)    # a line on a bar line reads bar n.1.00, not (n-1).5.00
            film = round(t - first_down, 3)         # time in audio/music.wav, cut at the first downbeat as printed
            lines.append({"t": round(t, 3), "film": film, "beat": round(b, 2), "bar": math.floor(b / 4) + 1,
                          "beat_in_bar": round(b % 4 + 1, 2), "text": m.group(3).strip()})
            print(f"{t:8.2f} s (film {film:7.2f} s) · beat {b:7.2f} · bar {math.floor(b / 4) + 1:>3}.{b % 4 + 1:4.2f}"
                  f" · {m.group(3).strip()}")
    if lines:
        print("film = the time in a clip cut at the first downbeat; a clip cut n bars later: film - n × bar")
    if a.json:
        json.dump({"audio": a.audio, "bpm": round(bpm, 3), "beat": round(beat, 5), "first_beat": round(first_beat, 3),
                   "first_downbeat": round(first_down, 3), "downbeat_confidence": round(conf, 2), "pulse": round(pulse, 2), "bars": bars,
                   "lines": lines}, open(a.json, "w", encoding="utf-8"), indent=1)
        print(f"grid -> {a.json}")

if __name__ == "__main__":
    if os.name == "nt" and not sys.flags.utf8_mode:   # Windows reads, writes and prints in a legacy code page
        import subprocess
        sys.exit(subprocess.call([sys.executable] + sys.argv, env=dict(os.environ, PYTHONUTF8="1")))
    for _s in (sys.stdin, sys.stdout, sys.stderr):
        getattr(_s, "reconfigure", lambda **k: None)(encoding="utf-8", errors="replace")
    sys.exit(main())
```

## File: scripts/brief.py
size: 14245
sha: 543295072045528cc91d6d0dfb873a4a23a75536
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/brief.py

```text
#!/usr/bin/env python3
"""The brief for a helper: the task, plus the kit's rules for that kind of task, quoted from the references as they
stand today (Python 3 stdlib). A helper starts cold and reads nothing on its own; what is not in its brief does not
exist for it, so the rules travel inside the brief.

  python3 tools/brief.py sound --task "Mix v2: the layered sound for B01-B08" --writes "audio/sfx/*,review/sound-v2.md" \
      --reads "shots.json,review/sound-audit.md" --done "every event in the audit has its sound; mix -16 to -14 LUFS" \
      --out logs/brief-sound.md        # --out, not > : PowerShell 5.1's > writes UTF-16
  python3 tools/brief.py list                    # the kinds and what each one carries
  python3 tools/brief.py overlap --task "<the user's first message>"   # also writes research/past-films.md
  python3 tools/brief.py wait research/predict.md                     # a helper waits for a file another one writes

Pass the whole output as the helper's prompt (Claude Code: the Agent tool; Cursor: Task). In a Film mode project the
guard stops a helper started without one.
"""
import argparse
import os
import re
import sys

def find_kit():
    """The kit this script came from, or, for the copy in a film's tools/, the kit its AGENTS.md names."""
    here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    if os.path.isdir(os.path.join(here, "references")):
        return here
    agents = os.path.join(here, "AGENTS.md")
    if os.path.isfile(agents):
        with open(agents, encoding="utf-8") as f:
            m = re.search(r"`([^`\n]+?)[\\/]SKILL\.md`", f.read())   # any form: C:\Kit, /My Kit, ~/kit
        kit = os.path.expanduser(m[1]) if m else ""
        if kit and os.path.isdir(os.path.join(kit, "references")):
            return kit
    if os.environ.get("CINECODE_KIT"):
        return os.environ["CINECODE_KIT"]
    sys.exit(f"brief: the kit is not found from {here} (AGENTS.md names no kit with a references/ folder); "
             f"run init_film.py <this film> --update-tools, or set CINECODE_KIT to the kit folder")

KIT = find_kit()

def film_folder():
    """The film this brief is for: the folder it runs in, or the film whose tools/ hold this copy; None outside a film."""
    for d in (os.getcwd(), os.path.dirname(os.path.dirname(os.path.abspath(__file__)))):
        if os.path.isfile(os.path.join(d, "STATE.md")):
            return d
    return None

# Each piece: (file, the line it starts with, the line it stops before; "" = the next heading of the same level).
SOUND_EVENTS = ("references/sound.md", "**What the picture does, the sound does**", "**Accents, not a sound per cut**")
ACCENTS = ("references/sound.md", "**Accents, not a sound per cut**", "")
MASTER = ("references/sound.md", "## 0. Master principles", "")
VOICE = ("references/sound.md", "## 4b. The film's own voice", "")
MUSIC = ("references/sound.md", "## 8. Music: genre, harmony, groove", "")
LONG = ("references/writing.md", "## 13. Long films", "")
SKELETON = ("references/writing.md", "## 14. Structure by length", "")
FACTS = ("references/writing.md", "## 16. Real people and events", "")
KEYS = ("references/writing.md", "## 15. Key moments", "")
COLD = ("references/writing.md", "## 8. First-time viewer check", "")
HALF = ("references/quick-mode.md", "**Half size, never half detail.**", "**Sound is built like the picture")
FINISH = ("references/finish.md", "", "")
PREDICT = ("references/writing.md", "**The prediction test (independent).**", "**The overlap check (independent).**")
OVERLAP = ("references/writing.md", "**The overlap check (independent).**", "The 10 angles stay private")
PICTURE = ("references/film-mode.md", "2. **Picture:**", "3. **Sound:**")
NOTES = ("references/film-mode.md", "## Notes from the user", "")
DRAFT = ("references/film-mode.md", "1. **Draft cut first:**", "2. **Picture:**")
WRITER = ("references/writer-prompt.md", "", "")
WORDS = ("references/writing.md", "## 5. Words: budget and lines", "")
SPECIFIC = ("references/writing.md", "## 12. Specificity test", "")
STEP2 = ("references/film-mode.md", "## Step 2: Roadmap", "")
STEP3 = ("references/film-mode.md", "## Step 3: Script and text", "")
STEP4 = ("references/film-mode.md", "## Step 4: Style frame", "")
SEARCH = ("references/assets-and-licences.md", "## 2. Searching", "")
LICLOG = ("references/assets-and-licences.md", "## 3. The licence log", "")
NEVER = ("references/assets-and-licences.md", "## 6. Never", "")
SOUND_ID = ("references/sound.md", "## 1. Sound identity", "")
LAYERS = ("references/sound.md", "## 3. Layers", "")

# kind: (what it covers, the rules it carries, the model, the effort). Taste and judgement stay on the chat's own model
# ("inherit"); work with a fixed recipe goes lower; counting, validating and measuring are scripts, never a helper.
KINDS = {
    "sound": ("sound design, foley, mix", [SOUND_EVENTS, MASTER, LAYERS, ACCENTS], "inherit", ""),
    "music": ("the score or a found recording", [MUSIC, ACCENTS], "inherit", ""),
    "voice": ("rendering narration takes (the user picks the voice)", [VOICE], "sonnet", "low"),
    "picture": ("building shots, the style frame", [PICTURE, STEP4, HALF, FINISH], "inherit", ""),
    "sourcing": ("finding real material and stock, logging its licence", [SEARCH, LICLOG, NEVER, PICTURE, FACTS], "sonnet", "medium"),
    "files": ("moving, renaming and listing files; licence rows from search results", [LICLOG], "haiku", "low"),
    "script": ("the script and its lines", [STEP3, WRITER, WORDS, LONG, KEYS, COLD, FACTS], "inherit", ""),
    "roadmap": ("structure, the curiosity map, the beat table", [STEP2, SPECIFIC, LONG, SKELETON, KEYS, SOUND_ID], "inherit", ""),
    "review": ("the cold comprehension test or an independent look", [COLD, DRAFT], "inherit", ""),
    "predict": ("the prediction test: the films the user's first message makes anyone expect, five lines, from that message alone", [PREDICT], "sonnet", "low"),
    "overlap": ("the overlap check: each candidate idea against the expected films and this user's own films, judged by someone who did not write them", [OVERLAP], "sonnet", "low"),
    "revision": ("a notes round: fixes and re-plans", [NOTES, HALF, FINISH], "inherit", ""),
}
MARK = "<!-- cinecode brief: {} -->"
DEFAULTS = {"predict": {"writes": "research/predict.md",
                        "reads": "nothing: the user's first message is quoted in the task, and it is all you get",
                        "done": "research/predict.md holds five lines, one expected film each: its subject, key images "
                                "and form"},
            "overlap": {"writes": "research/overlap.md",
                        "reads": "research/candidates.md (the ideas to judge), research/predict.md (the films anyone "
                                 "would expect; not there yet: python3 tools/brief.py wait research/predict.md), "
                                 "research/past-films.md (this user's own films)",
                        "done": "research/overlap.md holds one line per candidate: its number, then CLEAR, SHARES (one "
                                "element repeats: which, and with what) or OVERLAP (the idea itself repeats: with "
                                "what); a prediction by its number, a film by its title"}}
SHARED = ("STATE.md", "shots.json", "audio/mix.json", "LICENSES.csv")    # one owner: the main chat
VERDICT = ("End your report with these lines, exactly:\nVerdict: APPROVE | FIX | UNVERIFIABLE\nRed items: none, or one "
           "\"- \" line each (broken, must not reach a final)\nPolish: what would make it better (never a reason for "
           "another round)\nEvidence: the frames, strips and numbers you looked at\nUNVERIFIABLE when you could not see or "
           "measure what decides it (no film, no frames, a sound question only an ear answers): never APPROVE then.")

def piece(rel, start, stop):
    path = os.path.join(KIT, rel)
    with open(path, encoding="utf-8") as f:
        lines = f.read().splitlines()
    if not start:                                               # the whole file
        return "\n".join(lines).strip()
    at = next((i for i, l in enumerate(lines) if l.startswith(start)), None)
    if at is None:
        raise SystemExit(f"brief: '{start[:40]}' is no longer in {rel}; update brief.py")
    level = len(start) - len(start.lstrip("#")) if start.startswith("#") else 0
    out = [lines[at]]
    for l in lines[at + 1:]:
        if stop and l.startswith(stop):
            break
        if not stop and level and l.startswith("#") and len(l) - len(l.lstrip("#")) <= level:
            break
        if not stop and not level and (l.startswith("## ") or (l[:2].rstrip(".").isdigit() and l[1:3] == ". ")):
            break
        out.append(l)
    return "\n".join(out).strip()

def wait(path, most):
    """Until a file another helper writes is there (or the time is up): one call instead of a loop of sleeps."""
    import time
    end = time.time() + most
    while time.time() < end:
        if os.path.isfile(path) and os.path.getsize(path) > 0:
            print(f"{path} is there")
            return 0
        time.sleep(3)
    print(f"{path} is not there after {most:g} s: go on without it and say so in the report")
    return 1

def main():
    if len(sys.argv) > 1 and sys.argv[1] == "wait":
        ap = argparse.ArgumentParser(description="wait for a file another helper writes")
        ap.add_argument("cmd", choices=["wait"])
        ap.add_argument("file")
        ap.add_argument("--max", type=float, default=90, help="seconds at most (default 90)")
        a = ap.parse_args()
        return wait(a.file, a.max)
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("kind", help="list, or one of: " + ", ".join(KINDS))
    ap.add_argument("--task", help="what this helper does")
    ap.add_argument("--writes", help="the only files it may write, comma separated (globs, relative to the film)")
    ap.add_argument("--reads", help="the files it reads first, comma separated")
    ap.add_argument("--done", help="when it is done, measurably")
    ap.add_argument("--out", help="write the brief here (UTF-8) instead of stdout; use this on Windows, where > from "
                                  "PowerShell 5.1 would write UTF-16")
    a = ap.parse_args()
    if a.kind == "list":
        for k, (what, parts, model, effort) in KINDS.items():
            print(f"{k:<9} {model + (' ' + effort if effort else ''):<14} {what} · "
                  + ", ".join(f"{os.path.basename(f)} {s[:28].strip('#* ')}" for f, s, _ in parts))
        return 0
    if a.kind not in KINDS:
        sys.exit(f"brief: unknown kind {a.kind} (brief.py list)")
    for k, v in DEFAULTS.get(a.kind, {}).items():   # a kind with one fixed job: only --task (the material) is needed
        if not getattr(a, k):
            setattr(a, k, v)
    missing = [f"--{k}" for k in ("task", "writes", "reads", "done") if not getattr(a, k)]
    if missing:
        sys.exit("brief: " + ", ".join(missing) + " missing: a helper is told what it does, the only files it may "
                 "write, what it reads first and when it is done")
    shared = [w for w in a.writes.split(",") if w.strip() in SHARED]
    if shared:
        sys.exit(f"brief: {', '.join(shared)} belong to the main chat; the helper writes its own files and reports")
    film = film_folder()
    if a.kind in ("predict", "overlap") and not film:
        sys.exit("brief: run it in the film's folder (after init_film.py), so the helper writes into the film")
    if a.kind == "overlap":
        if not os.path.isfile(os.path.join(film, "research", "candidates.md")):
            sys.exit("brief: write the candidates first: research/candidates.md, numbered, each with its logline, key "
                     "images, worlds or places, form and seed (writing.md §1, The overlap check)")
        import dice
        n = dice.write_past(film, os.path.join("research", "past-films.md"))
        print(f"research/past-films.md: {n} films of this user", file=sys.stderr)
        a.task = ("Judge each candidate in research/candidates.md against the predictions and this user's own films. "
                  "The user's first message, quoted, so an overlap it asks for (a sequel, the same subject on purpose) "
                  "is known and counts as CLEAR: " + a.task)
    what, parts, model, effort = KINDS[a.kind]
    print("start it with: model " + model + (f", effort {effort}" if effort else "") + " (Claude Code: the Agent "
          "call's model and effort; inherit = the chat's own model)", file=sys.stderr)
    body = [MARK.format(a.kind), f"# Task ({what})\n\n{a.task}\n", "# Files\n",
            (f"PROJECT: {film} (the film's folder; every path below is inside it)\n" if film else "") +
            f"WRITES: {a.writes}\nREADS: {a.reads}\nDO NOT TOUCH: {', '.join(SHARED)} (the main chat owns them) and every "
            f"file not under WRITES\nDONE WHEN: {a.done}\n",
            "# The kit's rules for this task (quoted; they are not optional)\n"]
    body += [piece(*p) + "\n" for p in parts]
    body.append("# Report\n\nAt most 25 tool calls. Details go in the file under WRITES; reply in 10 lines or fewer: what "
                "was done, the files written, what was measured (the command and its result, not a claim), what is still "
                "open, and anything you saw that is outside your task." + ("\n\n" + VERDICT if a.kind == "review" else ""))
    text = "\n".join(body)
    if a.out:
        with open(a.out, "w", encoding="utf-8") as f:
            f.write(text + "\n")
        print(f"brief written to {a.out}", file=sys.stderr)
    else:
        print(text)
    return 0

if __name__ == "__main__":
    if os.name == "nt" and not sys.flags.utf8_mode:   # Windows reads, writes and prints in a legacy code page
        import subprocess
        sys.exit(subprocess.call([sys.executable] + sys.argv, env=dict(os.environ, PYTHONUTF8="1")))
    for _s in (sys.stdin, sys.stdout, sys.stderr):
        getattr(_s, "reconfigure", lambda **k: None)(encoding="utf-8", errors="replace")
    sys.exit(main())
```

## File: scripts/captions.py
size: 6261
sha: ea2ba7faf28b2142fc5aa0f4f92266e14eec5e4f
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/captions.py

```text
#!/usr/bin/env python3
"""Word-locked kinetic captions from narration word timings (talking-head style; Python 3 stdlib).

  python3 tools/captions.py cmd audio/vo/VO1.wav                       # the Whisper command for word timings
  python3 tools/captions.py build audio/vo/VO1.json@0.6 audio/vo/VO2.json@3.4 --accent "<word>,<number>" \
      --out audio/captions.json                                        # prints the CAPTIONS object for film.html

Our own TTS lines are aligned by transcribing them with Whisper word timestamps (the text is known, so check the
words against the script line); each file's words are shifted by the line's start in the film (@seconds = VO.at).
Word formats: [["word", start, end, prob], ...] · [{"word", "start", "end"}, ...] · {"segments": [{"words": [...]}]}
(openai-whisper --word_timestamps True) · {"words": [...]}.

Groups: a new caption group starts after --max-words words or --max-chars characters, after a pause longer than
--gap seconds, or after a word ending a sentence or clause. Accent words: the --accent list (Turkish suffix-aware: the
first 5 letters match, so "bedava" also marks "bedavaya"), plus numbers and prices automatically (one per group);
a trailing "!" in --accent also marks the word as a punch-in. KIT/references/typography.md §8.
"""
import argparse
import json
import os
import re
import sys

def load_words(path, offset=0.0):
    data = json.load(open(path, encoding="utf-8"))
    if isinstance(data, dict):
        data = data.get("words") or [w for s in data.get("segments", []) for w in s.get("words", [])]
    out = []
    for w in data:
        if isinstance(w, (list, tuple)):
            text, a, b = w[0], float(w[1]), float(w[2])
        else:
            text, a, b = w.get("word", w.get("text", "")), float(w["start"]), float(w["end"])
        text = text.strip()
        if text and b > a:
            out.append({"w": text, "t0": round(a + offset, 3), "t1": round(b + offset, 3)})
    return out

def stem(s):
    return re.sub(r"[^\wçğıöşüâîû]", "", s.replace("İ", "i").replace("I", "ı").lower())[:5]   # Turkish casing before lower()

def build(a):
    words = []
    for spec in a.files:
        path, _, at = spec.partition("@")
        words += load_words(path, float(at or 0))
    words.sort(key=lambda w: w["t0"])
    marks = {}
    for item in [x.strip() for x in (a.accent or "").split(",") if x.strip()]:
        marks[stem(item.rstrip("!"))] = item.endswith("!")
    groups, cur = [], []
    for i, w in enumerate(words):
        gap = w["t0"] - words[i - 1]["t1"] if i else 0
        chars = sum(len(words[j]["w"]) + 1 for j in cur) + len(w["w"])
        if cur and (len(cur) >= a.max_words or chars > a.max_chars or gap > a.gap or re.search(r"[.!?;:,…]$", words[i - 1]["w"])):
            groups.append(cur); cur = []
        cur.append(i)
    if cur:
        groups.append(cur)
    out_groups = []
    for gi, idx in enumerate(groups):
        auto = next((j for j in idx if re.search(r"\d", words[j]["w"])), None)
        for j in idx:
            w = words[j]
            w["group"] = gi
            st = stem(w["w"])
            w["accent"] = st in marks or j == auto
            w["punch"] = bool(marks.get(st))
        out_groups.append({"t0": words[idx[0]]["t0"], "t1": words[idx[-1]]["t1"], "words": idx})
    cap = {"words": words, "groups": out_groups}
    os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
    json.dump(cap, open(a.out, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
    shortest = min((w["t1"] - w["t0"] for w in words), default=0)
    print(f"{len(words)} words in {len(out_groups)} groups -> {a.out}; accents: "
          f"{', '.join(w['w'] for w in words if w['accent']) or '-'}; punch: {', '.join(w['w'] for w in words if w['punch']) or '-'}")
    if shortest and shortest < 0.12:
        print(f"note: a word lasts {shortest:.2f} s: the 0.12 s pop will overlap the next word there (fine for quick rhythm)")
    print("paste into film.html:\nconst CAPTIONS = " + json.dumps(cap, ensure_ascii=False, separators=(",", ":")) + ";")

def cmd(a):
    import shlex
    q = shlex.quote                                          # paths with spaces, apostrophes and Turkish letters
    base = os.path.splitext(a.wav)[0]
    lang, out = a.language, os.path.dirname(a.wav) or "."
    print("one of (whichever is installed):")
    print(f"  whisper {q(a.wav)} --language {q(lang)} --word_timestamps True --output_format json --output_dir {q(out)}")
    print(f"  mlx_whisper {q(a.wav)} --language {q(lang)} --word-timestamps True --output-format json --output-dir {q(out)}")
    code = (f"from faster_whisper import WhisperModel as M; import json; s,_=M('small').transcribe({a.wav!r}, language={lang!r}, "
            f"word_timestamps=True); json.dump([[w.word,w.start,w.end,w.probability] for x in s for w in x.words], "
            f"open({base + '.json'!r},'w'))")
    print(f"  python3 -c {q(code)}")
    print(f"then: python3 tools/captions.py build {q(base + '.json')}@<VO.at> --out audio/captions.json")

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    b = sub.add_parser("build")
    b.add_argument("files", nargs="+", help="words.json[@start seconds in the film]")
    b.add_argument("--accent", help="comma list; a trailing ! also punches in")
    b.add_argument("--max-words", type=int, default=3)
    b.add_argument("--max-chars", type=int, default=18)
    b.add_argument("--gap", type=float, default=0.25)
    b.add_argument("--out", required=True)
    c = sub.add_parser("cmd")
    c.add_argument("wav")
    c.add_argument("--language", default="tr")
    a = ap.parse_args()
    build(a) if a.cmd == "build" else cmd(a)

if __name__ == "__main__":
    if os.name == "nt" and not sys.flags.utf8_mode:   # Windows reads, writes and prints in a legacy code page
        import subprocess
        sys.exit(subprocess.call([sys.executable] + sys.argv, env=dict(os.environ, PYTHONUTF8="1")))
    for _s in (sys.stdin, sys.stdout, sys.stderr):
        getattr(_s, "reconfigure", lambda **k: None)(encoding="utf-8", errors="replace")
    sys.exit(main())
```

## File: scripts/capture.mjs
size: 30122
sha: 4be2d544eb2d9ba0526c0fd832ef65478732c7e6
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/capture.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/capture.mjs

## File: scripts/deal.py
size: 16730
sha: c4bd9030a7869b7f3376edb3deec41c8f3073677
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/deal.py

```text
#!/usr/bin/env python3
"""Deal 3 options from a bank so proposals vary from film to film, and log every pick (Python 3 stdlib).

  python3 tools/deal.py deal sound                    # 3 sound identities from the bank, as springboards
  python3 tools/deal.py deal angle                    # 3 idea angles for the writer's diverge step
  python3 tools/deal.py deal spark                    # 3 sparks: a seed from outside, a place, a form, a medium, two more
  python3 tools/deal.py deal seed --n <worlds>        # one seed from outside for each world of the film
  python3 tools/deal.py deal look --n 6               # 6 references for the style board from about 190 looks
  python3 tools/deal.py pick spark s2 --reason "<the user's idea, crossed with this hand's seed and place>"
  python3 tools/deal.py pick seed all --reason "<what each world takes from its seed>"
  python3 tools/deal.py pick sound own --reason "<the user's own sound concept>"
  python3 tools/deal.py redeal angle --reason "<why none of the three serves this story>"
  python3 tools/deal.py show
  python3 tools/deal.py past --scan <folder>          # remember the films made before under a folder (once)
  python3 tools/deal.py deal type --purpose cinematic --look <look id> --title "<the film's title>"   # 3 type pairings + specimens
  python3 tools/deal.py pick type <id> --reason "<why this pairing fits this film>"
  python3 tools/deal.py redeal type --reason "<the user's reason>"
  python3 tools/deal.py taste type <font id> --verdict reject --reason "<the user's reason>"

A spark's seed and a world's seed are real things drawn by chance from outside (dice.py: a museum object, a species,
something in space, an invention, a heritage site, a craft or custom), each from another pool, within a few
seconds. The random seed is the date plus the project folder name (--seed overrides), so the same project on the same
day gets the same hand. Options picked earlier in this project are left out. Asking again while a hand is open shows the same
hand: a new one needs `redeal` with a written reason. `pick` needs a one-line reason built on this film's facts;
`own` records the user's own answer. Everything is logged in history.json in the project folder.

The type bank (type_deal.py) deals font pairings from type-library.json, filtered by --purpose, --look and Turkish
coverage and weighted by the taste log; it writes the specimen cards' data into type/. Every type pick, pass,
re-deal and taste call is a taste record with the user's reason; --user-taste (or an existing ~/.cinecode/taste.json,
or CINECODE_TASTE) shares the log across projects. KIT/references/typography.md §9.3-10.
"""
import argparse
import datetime
import hashlib
import json
import os
import random
import sys

import dice
import type_deal

BANKS = {
    "sound": {
        "foley-forward": "foley-forward: exaggerated real sounds, timed to every action",
        "small-world-heard-huge": "small world heard huge: slowed textures for scale",
        "retro-pulse": "retro pulse: analog bass and arpeggio, a one-bar drop before the peak",
        "on-the-grid": "on the grid: every sound on one tempo, and the grid stops once",
        "under-pressure": "under pressure: muffled and close; one clear sound breaks through",
        "toy-orchestra": "toy orchestra: pizzicato and glockenspiel, a four-note theme",
        "close-and-quiet": "close and quiet: tactile textures, long near-silences",
        "only-the-microphone": "only what a microphone would hear: honest location sound, one place signature",
    },
    "angle": {
        "smallest-witness": "through the eyes of the smallest thing in the scene",
        "object-pov": "an object tells it",
        "backwards": "told from the ending back to the start",
        "one-moment-stretched": "one moment stretched across the whole film",
        "lifetime-compressed": "a whole lifetime in seconds",
        "scale-flip": "a scale flip: the tiny made vast, or the vast made tiny",
        "genre-swap": "the same events told in another genre",
        "obstacle-speaks": "the obstacle or rival as the narrator",
        "place-remembers": "the place remembers what happened here",
        "corrected-misreading": "a misreading that the ending corrects",
        "ritual-as-epic": "an everyday ritual told as an epic",
        "opposite-premise": "the opposite of what the idea first suggests",
        "just-before": "only the moment just before the event",
        "aftermath-only": "only the aftermath",
        "contradicting-message": "a message or instruction that the picture contradicts",
        "rule-breaks-once": "a rule of this world that breaks exactly once",
    },
}

SPARKS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sparks.json")
SPARK_AXES = ("place", "form", "medium")
SPARK_EXTRA = ("engine", "tone", "era", "colour")

def seen_path():
    return os.environ.get("CINECODE_SPARKS") or os.path.join(os.path.expanduser("~"), ".cinecode", "sparks-seen.json")

def load_seen():
    try:
        with open(seen_path(), encoding="utf-8") as fh:
            return set(json.load(fh))
    except (OSError, ValueError):
        return set()

def save_seen(seen, cards):
    try:
        os.makedirs(os.path.dirname(seen_path()), exist_ok=True)
        with open(seen_path(), "w", encoding="utf-8") as fh:
            json.dump((list(seen - set(cards)) + list(cards))[-300:], fh, ensure_ascii=False)
    except OSError:
        pass

def spark_hand(seed, n, out=()):
    """n sparks, each a seed from outside (every one from another pool), one card from every core axis and two of the
    others; no card twice in a deal, and none the user's last films were dealt (sparks-seen.json) while the axis has
    others left. Returns the hands' lines and their seeds in full."""
    with open(SPARKS, encoding="utf-8") as fh:
        data = json.load(fh)
    seen = load_seen()
    rng = random.Random(int(hashlib.sha256(f"{seed}|spark".encode()).hexdigest(), 16))
    used, hands = set(out) | seen, {}
    seeds = {f"s{i}": s for i, s in enumerate(dice.draw(f"{seed}|spark", n, avoid=used), 1)}
    for i in range(1, n + 1):
        parts = [f"seed: {seeds[f's{i}']['name']}"]
        for axis in SPARK_AXES + tuple(rng.sample(SPARK_EXTRA, 2)):
            pool = [x for x in data[axis] if x not in used] or data[axis]
            card = rng.choice(pool)
            used.add(card)
            parts.append(f"{axis}: {card}")
        hands[f"s{i}"] = " · ".join(parts)
    save_seen(seen, [p.split(": ", 1)[1] for h in hands.values() for p in h.split(" · ")])
    return hands, seeds

def seed_hand(seed, n, out=()):
    """n seeds from outside, one for each world of a film, every pool used before one comes back."""
    seen = load_seen()
    seeds = {f"w{i}": s for i, s in enumerate(dice.draw(f"{seed}|worlds", n, avoid=set(out) | seen), 1)}
    save_seen(seen, [s["name"] for s in seeds.values()])
    return {k: s["name"] for k, s in seeds.items()}, seeds

def look_hand(seed, n, out=()):
    """n references for the style board: cinematographers, directors, photographers, painters, studios, movements and
    crafts, none of those the user's last films were dealt while others are left."""
    with open(SPARKS, encoding="utf-8") as fh:
        looks = json.load(fh)["look"]
    seen = load_seen()
    rng = random.Random(int(hashlib.sha256(f"{seed}|look".encode()).hexdigest(), 16))
    pool = [x for x in looks if x not in seen and x not in out]
    pool = pool if len(pool) >= n else looks
    hand = {f"l{i}": x for i, x in enumerate(rng.sample(pool, min(n, len(pool))), 1)}
    save_seen(seen, hand.values())
    return hand

def load(path):
    if os.path.exists(path):
        with open(path, encoding="utf-8") as fh:
            return json.load(fh)
    return {"deals": []}

def save(path, h):
    os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)   # dealt before the film's folder exists
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(h, fh, ensure_ascii=False, indent=1)

def one_line(text):
    text = " ".join((text or "").split())
    if not text:
        sys.exit("a one-line reason is required")
    return text

def open_hand(h, cat):
    for d in reversed(h["deals"]):
        if d["category"] == cat:
            return d if d["pick"] is None else None
    return None

def deal_hand(h, cat, seed, n, extra_out=()):
    picked = {d["pick"] for d in h["deals"] if d["category"] == cat and d["pick"]}
    pool = sorted(k for k in BANKS[cat] if k not in picked and k not in extra_out)
    if len(pool) < n:                          # bank nearly used up: allow earlier dealt-but-unpicked options again
        pool = sorted(k for k in BANKS[cat] if k not in picked) or sorted(BANKS[cat])
    rng = random.Random(int(hashlib.sha256(f"{seed}|{cat}".encode()).hexdigest(), 16))
    return rng.sample(pool, min(n, len(pool)))

def show_hand(d):
    print(f"{d['category']} · seed {d['seed']}" + (f" · re-deal: {d['redeal_reason']}" if d.get("redeal_reason") else ""))
    for i, k in enumerate(d["dealt"], 1):
        print(f"  {i}. {k}: {(d.get('texts') or {}).get(k) or BANKS.get(d['category'], {}).get(k, '')}")
        s = (d.get("seeds") or {}).get(k)
        if s:
            print(f"       {dice.POOLS.get(s.get('pool'), 'a seed')}: {s.get('fact') or s['name']} {s.get('link', '')}")
    if d["category"] == "look":
        print("the board's directions start here: keep the three that serve this idea and differ in kind and in tone "
              "(at least one in daylight or colour); a name that fits nothing is swapped, with the reason in the pick")
    if d["category"] == "spark":
        print("springboards, not orders: cross the user's idea with each hand. The seed is a real thing drawn by chance, "
              "so the idea starts where habit would not go: take what it holds (a shape, a material, a motion, a sound, "
              "its story), never a copy of it, and never drop it for being unfamiliar; a card that fights the idea is "
              "dropped for another of its axis, a seed only by a redeal with its reason")
    if d["category"] == "seed":
        print("one seed for each world: build each world from what its seed holds (a shape, a material, a motion, a "
              "sound, its story), never a copy of it; the worlds the user named stay theirs; a seed that fights the "
              "story goes by a redeal with its reason")
        print("log what each world took: deal.py pick seed all --reason \"…\" (or the ids kept: w1,w3)")
        return
    print(f"pick one from this film's facts: deal.py pick {d['category']} <id> --reason \"…\"")

def fresh(category, seed, n, out=()):
    """A new hand's lines (and its seeds from outside) for the categories dealt from the deck."""
    if category == "spark":
        return spark_hand(seed, n, out)
    if category == "seed":
        return seed_hand(seed, n, out)
    return look_hand(seed, max(n, 6), out), None

def past_cmd(a):
    if a.scan:
        n = dice.scan(a.scan)
        print(f"remembered {n} films under {a.scan}; the memory holds {len(dice.load_films())} (its ideas are what "
              f"brief.py overlap checks new ideas against)")
        return
    films = dice.past(a.project)
    print(f"{len(films)} films of this user are remembered" + (": " + "; ".join(f["title"] for f in films[:5]) +
                                                                (" …" if len(films) > 5 else "") if films else ""))

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("cmd", choices=["deal", "redeal", "pick", "show", "taste", "past"])
    ap.add_argument("category", nargs="?", choices=sorted(BANKS) + ["look", "seed", "spark", "type"])
    ap.add_argument("choice", nargs="?", help="pick: an id from the open hand, or 'own' (seed: 'all' or ids, w1,w3)")
    ap.add_argument("--reason", help="pick and redeal: one line")
    ap.add_argument("--project", default=".")
    ap.add_argument("--seed", help="default: <date>:<project folder name>")
    ap.add_argument("--n", type=int, default=3)
    ap.add_argument("--scan", help="past: a folder whose films (made before) join the memory")
    type_deal.add_args(ap)
    a = ap.parse_args()
    if a.cmd == "past":
        return past_cmd(a)
    path = os.path.join(a.project, "history.json")
    h = load(path)
    base_seed = a.seed or f"{datetime.date.today().isoformat()}:{os.path.basename(os.path.abspath(a.project))}"

    if a.cmd == "show":
        for d in h["deals"]:
            if a.category in (None, d["category"]):
                print(f"{d['at']} {d['category']}: {', '.join(d['dealt'])} -> {d['pick'] or '(open)'}"
                      + (f": {d['reason']}" if d["reason"] else ""))
        return
    if not a.category:
        sys.exit("name a category: " + ", ".join(sorted(BANKS) + ["look", "seed", "spark", "type"]))
    if a.category == "type":
        return type_deal.run(a, h, path, base_seed, save, one_line)
    if a.cmd == "taste":
        sys.exit("taste works on the type category: deal.py taste type <id> --verdict like|reject --reason \"…\"")
    hand = open_hand(h, a.category)

    if a.cmd == "deal":
        if hand:
            print("a hand is already open (a new one needs: redeal --reason \"…\")")
            show_hand(hand)
            return
        rnd = sum(d["category"] == a.category for d in h["deals"]) + 1
        hand = {"category": a.category, "seed": f"{base_seed}#{rnd}", "dealt": [], "redeal_reason": None,
                "pick": None, "reason": None, "at": datetime.datetime.now().isoformat(timespec="minutes")}
        if a.category in ("spark", "seed", "look"):
            hand["texts"], seeds = fresh(a.category, hand["seed"], a.n)
            hand["dealt"] = sorted(hand["texts"], key=lambda k: int(k[1:]))
            if seeds:
                hand["seeds"] = seeds
        else:
            hand["dealt"] = deal_hand(h, a.category, hand["seed"], a.n)
        h["deals"].append(hand)
        save(path, h)
        show_hand(hand)

    elif a.cmd == "redeal":
        reason = one_line(a.reason)
        if not hand:
            sys.exit("no open hand: use deal")
        hand["pick"] = "(re-dealt)"
        hand["reason"] = reason
        shown = set()                          # every hand re-dealt since the last real pick stays out
        for d in reversed(h["deals"]):
            if d["category"] != a.category:
                continue
            if d["pick"] != "(re-dealt)":
                break
            shown |= set(d["dealt"])
        rnd = sum(d["category"] == a.category for d in h["deals"]) + 1
        new = {"category": a.category, "seed": f"{base_seed}#{rnd}", "dealt": [], "redeal_reason": reason,
               "pick": None, "reason": None, "at": datetime.datetime.now().isoformat(timespec="minutes")}
        if a.category in ("spark", "seed", "look"):
            old = [p.split(": ", 1)[1] if a.category == "spark" else p for d in h["deals"] if d["category"] == a.category
                   for x in (d.get("texts") or {}).values() for p in (x.split(" · ") if a.category == "spark" else [x])]
            new["texts"], seeds = fresh(a.category, new["seed"], a.n, out=old)
            new["dealt"] = sorted(new["texts"], key=lambda k: int(k[1:]))
            if seeds:
                new["seeds"] = seeds
        else:
            new["dealt"] = deal_hand(h, a.category, new["seed"], a.n, extra_out=shown)
        h["deals"].append(new)
        save(path, h)
        show_hand(new)

    else:
        reason = one_line(a.reason)
        if not hand:
            sys.exit("no open hand: use deal first")
        many = a.category == "seed" and (a.choice == "all" or set(str(a.choice).split(",")) <= set(hand["dealt"]))
        if a.choice not in hand["dealt"] and a.choice != "own" and not many:
            sys.exit(f"pick one of: {', '.join(hand['dealt'])} (or 'own' for the user's own answer"
                     + ("; 'all' or ids like w1,w3 for the worlds" if a.category == "seed" else "") + ")")
        hand["pick"], hand["reason"] = a.choice, reason
        save(path, h)
        print(f"logged: {a.category} = {a.choice}: {reason}")

if __name__ == "__main__":
    if os.name == "nt" and not sys.flags.utf8_mode:   # Windows reads, writes and prints in a legacy code page
        import subprocess
        sys.exit(subprocess.call([sys.executable] + sys.argv, env=dict(os.environ, PYTHONUTF8="1")))
    for _s in (sys.stdin, sys.stdout, sys.stderr):
        getattr(_s, "reconfigure", lambda **k: None)(encoding="utf-8", errors="replace")
    main()
```

## File: scripts/dice.py
size: 14962
sha: e9c4ffc182e4b55cac5f2673aef2ea3163f41ecc
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/dice.py

```text
#!/usr/bin/env python3
"""Seeds from outside, and the memory of this user's films (Python 3 stdlib; no CLI of its own).

A seed is a real thing drawn by chance, so a film's idea starts somewhere the model's habits would not go: a museum
object (the Cleveland Museum of Art's open access), a living species (among iNaturalist's 10,000 most observed),
something in space (JPL's pictures in NASA's image library), an invention (Wikipedia's timeline of historic
inventions), a heritage site or a craft or custom (UNESCO's lists, in seeds.json). `deal.py deal spark` puts one in
every hand, each from another pool; `deal.py deal seed --n <count>` deals one for each world of a film. A pool that
does not answer within a few seconds is replaced by one of the two lists, which need no network: a deal never waits
long or fails.

The memory: `make.py` records each film's title, idea and medium in ~/.cinecode/films.json (CINECODE_FILMS
overrides); `deal.py past --scan <folder>` adds films made before. `brief.py overlap` writes them, with the films on
the same shelf, into research/past-films.md for the helper that checks new ideas against them.
"""
import datetime
import hashlib
import html
import json
import os
import random
import re
import threading
import time
import urllib.parse
import urllib.request

UA = "cinecode-dice/1.0 (one request per seed for a film idea; low volume)"
WAIT = 8
DEADLINE = WAIT + 4      # a whole pool, its summary too: a slow trickle past it gives way to a list
SEEDS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "seeds.json")
POOLS = {"museum": "a museum object", "nature": "a living species", "sky": "something in space",
         "invention": "an invention", "place": "a heritage site", "custom": "a craft or custom"}
OFFLINE = ("place", "custom")
INSIDE = {"node_modules", "renders", "frames", "audio", "assets", "review", "research", "logs", "tools", "engine",
          "refs", "notes", "type", "fonts", "__pycache__"}

def get(url, wait=WAIT):
    req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=wait) as r:
        return json.load(r)

def clean(text, n=260):
    text = " ".join(re.sub(r"<[^>]+>", " ", html.unescape(str(text or ""))).split()).replace(" · ", ", ")
    if len(text) <= n:
        return text
    cut = text[:n]
    end = cut.rfind(". ")
    return cut[:end + 1] if end > n // 3 else cut.rsplit(" ", 1)[0] + "…"

def summary(title, wait=4):
    """The opening of the English Wikipedia article, or "" (a disambiguation page, or no answer)."""
    try:
        d = get("https://en.wikipedia.org/api/rest_v1/page/summary/" + urllib.parse.quote(title.replace(" ", "_"), safe=""),
                wait)
    except Exception:
        return ""
    text = clean(d.get("extract"))
    return "" if d.get("type") == "disambiguation" or re.match(r"This (?:is a|article is a) list", text) else text

def museum(rng, avoid):
    d = get("https://openaccess-api.clevelandart.org/api/artworks/?has_image=1&cc0=1&limit=1&skip=%d&fields=title,type,"
            "creation_date,culture,technique,did_you_know,wall_description,url" % rng.randrange(40000))
    a = d["data"][0]
    what = ", ".join(x for x in (a.get("type"), "; ".join(a.get("culture") or []), a.get("creation_date"),
                                 a.get("technique")) if x)
    more = a.get("did_you_know") or a.get("wall_description") or ""
    return {"name": clean(a["title"], 120), "fact": clean(what + (". " + more if more else "")), "link": a.get("url", "")}

def nature(rng, avoid):
    t = get("https://api.inaturalist.org/v1/taxa?rank=species&is_active=true&order_by=observations_count&per_page=1"
            "&page=%d" % rng.randint(1, 10000))["results"][0]
    common, latin = t.get("preferred_common_name") or "", t["name"]
    wiki = (t.get("wikipedia_url") or "").rsplit("/wiki/", 1)
    fact = summary(urllib.parse.unquote(wiki[1])) if len(wiki) == 2 else ""
    return {"name": f"{common} ({latin})" if common else latin,
            "fact": fact or f"a species of {t.get('iconic_taxon_name') or 'living thing'}",
            "link": t.get("wikipedia_url") or f"https://www.inaturalist.org/taxa/{t['id']}"}

def sky(rng, avoid):
    year = rng.randint(1996, datetime.date.today().year - 1)       # the library dates JPL's pictures from 1996 on
    url = f"https://images-api.nasa.gov/search?media_type=image&center=JPL&year_start={year}&year_end={year}"
    c = get(url)["collection"]
    pages = min(100, -(-int((c.get("metadata") or {}).get("total_hits") or 0) // 100))
    if pages > 1:
        c = get(url + f"&page={rng.randint(1, pages)}")["collection"]
    items = [i["data"][0] for i in c.get("items") or [] if i.get("data")]
    i = rng.choice([i for i in items if len(clean(i.get("description"))) >= 60 and i.get("title") not in avoid] or items)
    return {"name": clean(i["title"], 120), "fact": clean(i.get("description")),
            "link": "https://images.nasa.gov/details/" + urllib.parse.quote(i["nasa_id"])}

def invention(rng, avoid):
    req = urllib.request.Request("https://en.wikipedia.org/w/index.php?title=Timeline_of_historic_inventions&action=raw",
                                 headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=WAIT) as r:
        wiki = re.split(r"\n==\s*(?:See also|References|Notes|Further reading|External links)\s*==", r.read().decode("utf-8", "replace"))[0]
    rows = []
    for line in wiki.splitlines():
        links = re.findall(r"\[\[([^\]|#]+)", line) if line.startswith("*") else []
        links = [x for x in links if not re.match(r"(?:Lists? of|Timeline of|Outline of|Category:|File:|Image:|"
                                                   r"\d+(?: BC)?(?: in [\w ]+)?$)", x, re.I)]
        head = line.lstrip("* ").split(":", 1)[0] if ":" in line else ""
        dated = len(head) < 60 and re.search(r"\d|century|millennium", head, re.I)     # an entry starts with its date
        if links and dated:
            text = re.sub(r"<ref[^>]*/>|<ref[^>]*>.*?</ref>|\{\{[^{}]*\}\}|'''?", "", line.lstrip("* "))
            rows.append((clean(re.sub(r"\[\[(?:[^\]|]*\|)?([^\]]*)\]\]", r"\1", text), 170), links[0]))
    name, title = rng.choice([r for r in rows if r[0] not in avoid] or rows)
    return {"name": name, "fact": summary(title),
            "link": "https://en.wikipedia.org/wiki/" + urllib.parse.quote(title.replace(" ", "_"))}

def listed(kind, network=True):
    def draw(rng, avoid):
        with open(SEEDS, encoding="utf-8") as fh:
            rows = json.load(fh)[kind]
        name, desc, where, title = rng.choice([r for r in rows if r[0] not in avoid] or rows)
        fact = (summary(title) if network else "") or ", ".join(x for x in (desc, where) if x)
        return {"name": name, "fact": fact, "link": "https://en.wikipedia.org/wiki/" + urllib.parse.quote(title)}
    return draw

FETCH = {"museum": museum, "nature": nature, "sky": sky, "invention": invention,
         "place": listed("place"), "custom": listed("custom")}

def draw(seed, n=3, avoid=()):
    """n seeds, each from another pool while there are pools left (the order shuffled by the seed); every one a dict
    with its pool, name, fact and link. Pools are asked together; one that fails or is slow gives way to a list."""
    rng = random.Random(int(hashlib.sha256(f"{seed}|dice".encode()).hexdigest(), 16))
    order = []
    while len(order) < n:
        order += rng.sample(list(POOLS), len(POOLS))
    order, avoid = order[:n], set(avoid)
    rngs = [random.Random(rng.random()) for _ in order]

    found = {}

    def one(i):
        try:
            s = FETCH[order[i]](rngs[i], avoid)
            if s.get("name"):
                found[i] = dict(s, pool=order[i])
        except Exception:
            pass
    if os.environ.get("CINECODE_DICE") != "offline":                # offline: the two lists alone (tests, no network)
        workers = [threading.Thread(target=one, args=(i,), daemon=True) for i in range(n)]
        for w in workers:
            w.start()
        end = time.monotonic() + DEADLINE
        for w in workers:
            w.join(max(0.0, end - time.monotonic()))
    got = [found.get(i) for i in range(n)]
    for i, s in enumerate(got):
        if s is None:
            used = {g["pool"] for g in got if g}
            kind = next((k for k in OFFLINE if k not in used), OFFLINE[i % 2])
            got[i] = dict(listed(kind, network=False)(rngs[i], avoid | {g["name"] for g in got if g}), pool=kind)
    return got

def films_path():
    return os.environ.get("CINECODE_FILMS") or os.path.join(os.path.expanduser("~"), ".cinecode", "films.json")

IDEA = re.compile(r"^\s*-\s*\*\*(?:Idea|Logline)\b[^*\n]*:\*\*\s*(.+)$", re.M | re.I)

def film_entry(folder):
    """A film's title, idea and medium from its own files, or None while it has no idea yet."""
    def text(name):
        try:
            with open(os.path.join(folder, name), encoding="utf-8", errors="replace") as fh:
                return fh.read()
        except OSError:
            return ""
    state = text("STATE.md")
    idea = next((m.group(1).strip() for name in ("STATE.md", "ROADMAP.md", "SCRIPT.md")
                 for m in IDEA.finditer(state if name == "STATE.md" else text(name))
                 if not m.group(1).strip().startswith("<")), "")
    if not idea:
        return None
    title = re.match(r"#\s*State:\s*(.+)", state)
    title = re.sub(r"\s*\((?:working title|Quick mode)\)\s*$", "", title.group(1)).strip() if title else ""
    medium = re.search(r"^\s*-\s*\*\*Medium:\*\*\s*([^<\s].*)$", state, re.M)
    try:
        at = datetime.date.fromtimestamp(os.path.getmtime(os.path.join(folder, "STATE.md"))).isoformat()
    except OSError:
        at = ""
    return {"title": title if title and not title.startswith("<") else os.path.basename(os.path.abspath(folder)),
            "idea": clean(idea, 400), "medium": clean(medium.group(1), 80) if medium else "", "at": at}

def load_films():
    try:
        with open(films_path(), encoding="utf-8") as fh:
            films = json.load(fh)
        return films if isinstance(films, dict) else {}
    except (OSError, ValueError):
        return {}

def save_films(films):
    """Written whole and renamed into place, so a chat reading it never sees half of it."""
    path = films_path()
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = f"{path}.{os.getpid()}.{threading.get_ident()}.tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(films, fh, ensure_ascii=False, indent=1)
    for _ in range(40):                  # Windows: a scanner or indexer may hold the file for a moment
        try:
            os.replace(tmp, path)
            return
        except PermissionError:
            time.sleep(0.05)
    os.replace(tmp, path)

def update_films(change):
    """One read, change and write of the memory at a time across chats: a lock file, given up after five seconds (then
    OSError), taken over when a stopped chat left it."""
    lock, end = films_path() + ".lock", time.monotonic() + 5
    os.makedirs(os.path.dirname(lock), exist_ok=True)
    while True:
        try:
            os.close(os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
            break
        except (FileExistsError, PermissionError):     # Windows: a lock still being deleted refuses a new one
            try:
                if time.time() - os.path.getmtime(lock) > 30:
                    os.remove(lock)
                    continue
            except OSError:
                pass
            if time.monotonic() > end:
                raise OSError("the film memory is busy")
            time.sleep(0.05)
    try:
        if os.path.exists(films_path()):
            with open(films_path(), encoding="utf-8") as fh:
                films = json.load(fh)                # a memory it cannot read is left as it is, never written over
        else:
            films = {}
        change(films)
        save_films(films)
    except ValueError:
        raise OSError("the film memory cannot be read")
    finally:
        for _ in range(40):
            try:
                os.remove(lock)
                break
            except PermissionError:          # Windows: the next chat's open of the lock can hold it for a moment
                time.sleep(0.05)
            except OSError:
                break

def remember(folder="."):
    """Record this film in the memory (its idea as it stands now); quiet when it has none yet or the file is busy."""
    entry = film_entry(folder)
    if not entry:
        return False
    try:
        update_films(lambda films: films.__setitem__(os.path.abspath(folder), entry))
        return True
    except OSError:
        return False

def scan(root, depth=4):
    """Every film under a folder (a folder with a STATE.md and an idea), into the memory; how many were found."""
    films = {}
    for here, dirs, files in os.walk(os.path.abspath(root)):
        if "STATE.md" in files:
            entry = film_entry(here)
            if entry:
                films[here] = entry
            dirs[:] = []                                     # never a film inside a film
            continue
        if here[len(os.path.abspath(root)):].count(os.sep) >= depth:
            dirs[:] = []
        dirs[:] = [d for d in dirs if not d.startswith(".") and d not in INSIDE]
    update_films(lambda known: known.update(films))
    return len(films)

def past(project="."):
    """This user's other films: the memory, plus the films on this film's shelf it has not met yet; newest first."""
    here = os.path.abspath(project)
    films = load_films()
    shelf = os.path.dirname(here)
    try:
        names = os.listdir(shelf)
    except OSError:
        names = []
    for name in names:
        d = os.path.join(shelf, name)
        if d not in films and os.path.isfile(os.path.join(d, "STATE.md")):
            entry = film_entry(d)
            if entry:
                films[d] = entry
    same = lambda a: os.path.normcase(a) == os.path.normcase(here)
    return sorted((dict(v, folder=k) for k, v in films.items() if not same(k)), key=lambda f: f.get("at", ""),
                  reverse=True)

def write_past(project=".", out="research/past-films.md"):
    films = past(project)
    lines = [f"# This user's films ({len(films)})", "",
             "Each: title · idea · medium. A new idea that shares its subject, key images, worlds or form with one of "
             "these repeats a film this user already has.", ""]
    lines += [f"- {f['title']} · {f['idea']}" + (f" · {f['medium']}" if f.get("medium") else "") for f in films] or \
             ["- none yet"]
    path = os.path.join(project, out)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w", encoding="utf-8") as fh:
        fh.write("\n".join(lines) + "\n")
    return len(films)
```

## File: scripts/direction.py
size: 23798
sha: ae87d85b3e969ab88b979c804ff7fe1970805862
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/direction.py

```text
#!/usr/bin/env python3
"""Directions for a Film mode idea: three looks side by side, each a style plus film stills, then the picked one
measured into the film's look (Python 3 stdlib + ffmpeg; no installs).

  # 1. stills per direction (mood only): films whose look fits, by title, or a cinematographer or genre page
  python3 tools/asset_search.py search --q "<film title>" --sources filmgrab --n 6 --out research/dir-a1
  python3 tools/asset_search.py search --q "cat:<cinematographer>" --sources filmgrab --n 3 --out research/dir-a2
  # 2. one image: one row per direction (style poster, up to four stills, measured palette)
  python3 tools/direction.py board --row "A|<label>|<style id>|research/dir-a1,research/dir-a2" \
      --row "B|…|…|…" --row "C|…|…|…" --out review/directions.jpg
  # 3. after the pick: measure its stills into a look patch for engine/looks.mjs
  python3 tools/direction.py measure --dirs research/dir-a1,research/dir-a2 --out research/direction
  # 4. at the style frame: the still against the picked direction
  python3 tools/direction.py compare review/style-v1.png --against research/direction/direction.json

Measured per still and pooled: palette (k-means in CIELAB, k = 5, sorted dark to light, accent = most chromatic),
luminance percentiles (p5, p50, p95), contrast (p95 - p5), dark and bright shares, saturation (mean CIELAB chroma),
warmth (mean b* of the mid-tones), vignette (corner to centre), and an **estimate** of the key light: the centroid of
the brightest pixels gives the side (frame left or right, or frontal) and the height. The estimate is a starting
point for `light.azimuthDeg` / `elevationDeg` (scene.html: 180 = from the camera, about 90 = frame right, about 270 =
frame left); the eye decides at the style frame.

Film stills are copyrighted: they are thumbnails for choosing and measuring, never fetched in full, never in a frame.
"""
import argparse
import glob
import json
import math
import os
import shutil
import subprocess
import sys
from pathlib import Path

HERE = os.path.dirname(os.path.abspath(__file__))
W = 96                                       # analysis width: palette and tone, not detail
BASE_FAMILIES = {"cinematic-realistic", "anime-cel", "pastel-storybook", "claymation", "papercut-collage",
                 "retro-synthwave-vhs", "glossy-commercial", "handmade-ink"}

def decode(path, width=W):
    probe = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height",
                            "-of", "csv=p=0", path], capture_output=True, text=True).stdout.strip().split(",")
    try:
        w0, h0 = int(probe[0]), int(probe[1])
    except (ValueError, IndexError):
        raise SystemExit(f"{path} is not a picture ffprobe can read") from None
    h = max(2, round(width * h0 / w0 / 2) * 2)
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-vf", f"scale={width}:{h}", "-frames:v", "1",
                          "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True).stdout
    px = [tuple(raw[i:i + 3]) for i in range(0, len(raw) - 2, 3)]
    return px, width, len(px) // width

def lin(c):
    c /= 255
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4

def to_lab(p):
    r, g, b = (lin(v) for v in p)
    x, y, z = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, 0.2126 * r + 0.7152 * g + 0.0722 * b, \
              (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
    f = lambda t: t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116
    fx, fy, fz = f(x), f(y), f(z)
    return 116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)

def lab_hex(L):
    l, a, b = L
    fy = (l + 16) / 116
    fx, fz = fy + a / 500, fy - b / 200
    inv = lambda t: t ** 3 if t ** 3 > 0.008856 else (t - 16 / 116) / 7.787
    x, y, z = inv(fx) * 0.95047, inv(fy), inv(fz) * 1.08883
    rgb = (3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.2040 * y + 1.0570 * z)
    g = lambda c: 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055
    return "#" + "".join(f"{round(max(0, min(1, g(c))) * 255):02x}" for c in rgb)

def kmeans(labs, k=5, iters=16):
    step = max(1, len(labs) // 4000)
    pts = labs[::step]
    srt = sorted(pts, key=lambda p: p[0])
    cent = [srt[int((i + 0.5) * len(srt) / k)] for i in range(k)]   # seeded by lightness: deterministic
    for _ in range(iters):
        groups = [[] for _ in range(k)]
        for p in pts:
            j = min(range(k), key=lambda i: (p[0] - cent[i][0]) ** 2 + (p[1] - cent[i][1]) ** 2 + (p[2] - cent[i][2]) ** 2)
            groups[j].append(p)
        cent = [tuple(sum(c[d] for c in g) / len(g) for d in range(3)) if g else cent[i] for i, g in enumerate(groups)]
    shares = [len(g) / len(pts) for g in groups]
    return sorted(zip(cent, shares), key=lambda cs: cs[0][0])

def pct(vals, q):
    return vals[min(len(vals) - 1, int(q * len(vals)))]

def measure_one(path):
    px, w, h = decode(path)
    labs = [to_lab(p) for p in px]
    Ls = sorted(l[0] for l in labs)
    p5, p50, p95, p85 = pct(Ls, 0.05), pct(Ls, 0.5), pct(Ls, 0.95), pct(Ls, 0.85)
    chroma = [math.hypot(a, b) for l, a, b in labs if l > 15]
    mids = [b for l, a, b in labs if 30 < l < 75]
    bright = [(i % w, i // w) for i, l in enumerate(labs) if l[0] >= p85]
    cx = (sum(x for x, _ in bright) / len(bright) / (w - 1)) * 2 - 1 if bright else 0.0
    cy = (sum(y for _, y in bright) / len(bright) / (h - 1)) * 2 - 1 if bright else 0.0
    cw, ch = max(1, w // 6), max(1, h // 6)
    corner = [labs[y * w + x][0] for y in list(range(ch)) + list(range(h - ch, h)) for x in list(range(cw)) + list(range(w - cw, w))]
    centre = [labs[y * w + x][0] for y in range(h // 3, 2 * h // 3) for x in range(w // 3, 2 * w // 3)]
    return {"file": path, "labs": labs, "p5": p5, "p50": p50, "p95": p95, "contrast": p95 - p5,
            "dark": sum(l < 15 for l in Ls) / len(Ls), "bright": sum(l > 85 for l in Ls) / len(Ls),
            "chroma": sum(chroma) / max(1, len(chroma)), "warmth": sum(mids) / max(1, len(mids)),
            "vignette": (sum(corner) / len(corner)) / max(1.0, sum(centre) / len(centre)), "key": (cx, cy)}

def pool(ms):
    labs = [l for m in ms for l in m["labs"]]
    pal = kmeans(labs)
    avg = lambda k: sum(m[k] for m in ms) / len(ms)
    cx, cy = sum(m["key"][0] for m in ms) / len(ms), sum(m["key"][1] for m in ms) / len(ms)
    return {"palette": [{"hex": lab_hex(c), "share": round(s, 3), "chroma": round(math.hypot(c[1], c[2]), 1)} for c, s in pal],
            "p5": avg("p5"), "p50": avg("p50"), "p95": avg("p95"), "contrast": avg("contrast"), "dark": avg("dark"),
            "bright": avg("bright"), "chroma": avg("chroma"), "warmth": avg("warmth"), "vignette": avg("vignette"),
            "key": (cx, cy)}

def words(P):
    key = "low-key" if P["p50"] < 35 else "high-key" if P["p50"] > 62 else "mid-key"
    con = "hard" if P["contrast"] > 70 else "soft" if P["contrast"] < 45 else "medium"
    sat = "muted" if P["chroma"] < 14 else "saturated" if P["chroma"] > 32 else "moderate"
    warm = "warm" if P["warmth"] > 6 else "cool" if P["warmth"] < -4 else "neutral"
    cx = P["key"][0]
    side = "frame right" if cx > 0.18 else "frame left" if cx < -0.18 else "frontal"
    return f"{key}, {con} contrast, {sat}, {warm}; key light {side}, {'high' if P['key'][1] < -0.2 else 'low' if P['key'][1] > 0.2 else 'mid'} (estimate)"

def look_patch(P):
    pal = P["palette"]
    accent = max(pal[1:], key=lambda c: c["chroma"] * (1.4 if c["share"] < 0.2 else 1))
    rest = [c["hex"] for c in pal if c is not accent]
    cx, cy = P["key"]
    az = 180 if abs(cx) <= 0.18 else (180 - 70 * min(1, abs(cx) / 0.6)) if cx > 0 else (180 + 70 * min(1, abs(cx) / 0.6))
    el = round(max(10, min(70, 35 - 40 * cy)))
    return {
        "palette": [rest[0], rest[1], rest[2], rest[3] if len(rest) > 3 else rest[2], accent["hex"]],
        "light": {"azimuthDeg": round(az), "elevationDeg": el,
                  "ambient": round(max(0.1, min(0.9, 0.15 + 0.9 * (P["p5"] / 40))), 2),
                  "exposure": round(max(0.7, min(1.4, 0.75 + P["p50"] / 100)), 2)},
        "post": {"contrast": round(max(-0.2, min(0.3, (P["contrast"] - 55) / 150)), 2),
                 "saturation": round(max(-0.5, min(0.3, (P["chroma"] - 22) / 60)), 2),
                 "vignette": {"darkness": round(max(0, min(0.8, (1 - P["vignette"]) * 1.4)), 2)}},
    }

def stills(dirs, limit=None):
    out = []
    for d in dirs:
        res = json.load(open(os.path.join(d, "results.json"), encoding="utf-8"))["results"]
        for r in res:
            p = os.path.join(d, "thumbs", f"{r['n']:02d}.img")
            if os.path.exists(p):
                out.append((p, r))
    return out[:limit] if limit else out

def palette_strip(pal, width, path):
    pal = [c for c in pal if c["share"] > 0.001] or pal[:1]   # a one-colour reference leaves empty clusters
    total = sum(c["share"] for c in pal) or 1.0
    widths = [max(1, int(c["share"] / total * width)) for c in pal]
    widths[widths.index(max(widths))] += width - sum(widths)   # the widest takes the rounding, so the sum is exact
    parts = [f"color=c=0x{c['hex'][1:]}:s={w}x40[c{i}]" for i, (c, w) in enumerate(zip(pal, widths))]
    graph = ";".join(parts) + ";" + ("".join(f"[c{i}]" for i in range(len(pal))) + f"hstack=inputs={len(pal)}"
                                     if len(pal) > 1 else "[c0]null")
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "nullsrc=s=16x16", "-filter_complex", graph,
                    "-frames:v", "1", path], check=True)

def page(img, captions=()):
    """A page beside an image that shows it at full width, with optional numbered captions above it. Its path, clicked
    in the Claude Code desktop app, opens it big in the app's own browser pane (elsewhere in the browser); the app does
    not show images inside a chat message."""
    import html as _h
    p = os.path.splitext(str(img))[0] + ".html"
    name = _h.escape(os.path.basename(str(img)))
    caps = "".join(f"<li>{_h.escape(c)}</li>" for c in captions)
    open(p, "w", encoding="utf-8").write(
        f'<!doctype html><meta charset=utf-8><title>{name}</title><style>body{{margin:0;background:#111;color:#eee;'
        f'font:18px/1.5 -apple-system,Helvetica,Arial,sans-serif}}ul{{margin:0;padding:14px 18px;list-style:none}}'
        f'img{{display:block;width:100%;height:auto}}</style>' + (f"<ul>{caps}</ul>" if caps else "") +
        f'<img src="{name}">')
    return os.path.abspath(p)

def show(path):
    """Put a board, a sheet or a film in front of the user: Preview on macOS, the default viewer elsewhere.
    CINECODE_NO_OPEN=1 turns it off (tests, servers)."""
    if os.environ.get("CINECODE_NO_OPEN") or not os.path.exists(str(path)):
        return False
    if not os.environ.get("CINECODE_OPEN"):
        return False                              # nothing opens a window: paths go into the message; CINECODE_OPEN=1 opens them
    try:
        if sys.platform == "darwin":
            subprocess.Popen(["open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        elif sys.platform.startswith("win"):
            os.startfile(str(path))
        elif shutil.which("xdg-open") and (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
            subprocess.Popen(["xdg-open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        else:
            return False
    except OSError:
        return False
    return True

def labelled_board(styles, rows, width, out):
    """Each row under a bar with its number and the name the style question uses for it, nothing else, shot by headless
    Chrome. False without Chrome: the plain stack is used then."""
    exe = styles.chrome()
    if not exe:
        return False
    import html as H
    import tempfile
    bar, total, parts = 44, 0, []
    for path, _, letter, label, films in rows:
        h = int(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "stream=height", "-of", "csv=p=0", path],
                               capture_output=True, text=True).stdout.strip() or 0)
        total += bar + h + 8
        parts.append(f'<div class="b"><i>{H.escape(letter)}</i><b>{H.escape(label)}</b></div>'
                     f'<img src="{H.escape(Path(path).resolve().as_uri())}" style="height:{h}px">')
    page = ("<!doctype html><meta charset=utf-8><style>body{margin:0;background:#000;font-family:-apple-system,Helvetica,"
            "Arial,sans-serif}.b{height:44px;display:flex;align-items:center;gap:12px;padding:0 10px;color:#eee}"
            ".b i{font:700 20px/32px -apple-system,Helvetica,Arial,sans-serif;font-style:normal;background:#f2c230;color:#111;"
            "min-width:32px;height:32px;border-radius:16px;text-align:center}.b b{font-size:19px;white-space:nowrap}"
            "img{display:block;margin-bottom:8px}</style>" + "".join(parts))
    with tempfile.TemporaryDirectory() as tmp:
        src, png = os.path.join(tmp, "board.html"), os.path.join(tmp, "board.png")
        open(src, "w", encoding="utf-8").write(page)
        r = subprocess.run([exe, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--allow-file-access-from-files",
                            f"--window-size={width},{total}", f"--screenshot={png}", Path(src).resolve().as_uri()],
                           capture_output=True, timeout=60)
        if r.returncode != 0 or not os.path.exists(png):
            return False
        subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", png, "-q:v", "4", out], check=True)
    return True

def one_tone(measured):
    """Three directions that all sit in the dark, or all without colour, are one choice offered three times."""
    if len(measured) < 2:
        return ""
    why = []
    if max(P["p50"] for P in measured) < 45:
        why.append("every row is low-key")
    if max(P["chroma"] for P in measured) < 25:
        why.append("no row carries colour (all muted or monochrome)")
    if not why:
        return ""
    return ("ONE TONE: " + " and ".join(why) + ". Swap one direction for one in daylight or in colour (high-key light, "
            "saturated fills, painted or graphic colour) that still serves this story; keep an all-dark board only when "
            "the story happens only at night, and then say so to the user in one line")

def cmd_board(a):
    sys.path.insert(0, HERE)
    import styles
    data = styles.index()
    tmp = os.path.join(os.path.dirname(os.path.abspath(a.out)), ".direction-board")
    os.makedirs(tmp, exist_ok=True)
    cell_w, cell_h, rows_img, measured = 320, 180, [], []
    for ri, row in enumerate(a.row):
        letter, label, style_id, dirs = (row.split("|") + ["", "", "", ""])[:4]
        cells = []
        recipe = next((s for s in data["styles"] if s["id"] == style_id), None)
        if recipe and recipe.get("poster"):      # a library recipe brings its poster; a base family has none
            cells.append(styles.fetch(recipe["poster"], os.path.join(styles.CACHE, data.get("commit", "x")[:12], recipe["id"] + ".jpg")))
        elif not recipe and style_id and style_id not in BASE_FAMILIES:
            raise SystemExit(f"row {letter}: unknown style {style_id} (a base family or styles.py list)")
        found = stills([d for d in dirs.split(",") if d], limit=4)
        cells += [p for p, _ in found]
        if not cells:
            raise SystemExit(f"row {letter}: no style poster or stills")
        P = pool([measure_one(p) for p, _ in found]) if found else None
        try:
            measured.append(P or pool([measure_one(cells[0])]))
        except Exception:                        # a poster that cannot be measured leaves the check to the others
            pass
        inputs, chain = [], []
        for i, c in enumerate(cells):
            inputs += ["-i", c]
            chain.append(f"[{i}:v]scale={cell_w}:{cell_h}:force_original_aspect_ratio=increase,crop={cell_w}:{cell_h},setsar=1[v{i}]")
        graph = ";".join(chain) + ";" + "".join(f"[v{i}]" for i in range(len(cells))) + \
            (f"hstack=inputs={len(cells)}" if len(cells) > 1 else "null")
        strip_w = cell_w * len(cells)
        row_img = os.path.join(tmp, f"row{ri}.png")
        subprocess.run(["ffmpeg", "-v", "error", "-y", *inputs, "-filter_complex", graph, "-frames:v", "1", row_img], check=True)
        if P:
            strip = os.path.join(tmp, f"strip{ri}.png")
            palette_strip(P["palette"], strip_w, strip)
            stacked = os.path.join(tmp, f"rowp{ri}.png")
            subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", row_img, "-i", strip, "-filter_complex", "vstack=inputs=2",
                            "-frames:v", "1", stacked], check=True)
            row_img = stacked
        films = ", ".join(dict.fromkeys(f"{r['title']} ({r.get('year', '')})".replace(" ()", "") for _, r in found))
        rows_img.append((row_img, strip_w, letter, label, films))
        print(f"{letter}. {label} · style {style_id or '-'} · {films or 'no stills'}")
        if P:
            print(f"   {words(P)} · palette {' '.join(c['hex'] for c in P['palette'])}")
    maxw = max(r[1] for r in rows_img)
    os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
    labelled = labelled_board(styles, rows_img, maxw, a.out)
    inputs, chain = [], []
    for i, (p, w, *_) in enumerate(rows_img if not labelled else []):
        inputs += ["-i", p]
        chain.append(f"[{i}:v]pad={maxw}:ih+8:0:0:black[r{i}]")
    if not labelled:
        graph = ";".join(chain) + ";" + "".join(f"[r{i}]" for i in range(len(rows_img))) + \
            (f"vstack=inputs={len(rows_img)}" if len(rows_img) > 1 else "null")
        subprocess.run(["ffmpeg", "-v", "error", "-y", *inputs, "-filter_complex", graph, "-frames:v", "1", "-q:v", "4", a.out],
                       check=True)
    for f in os.listdir(tmp):
        os.remove(os.path.join(tmp, f))
    os.rmdir(tmp)
    tone = one_tone(measured)
    if tone:
        print(tone)
    caps = [f'{r.split("|")[0]} · {(r.split("|") + [""])[1]}' for r in a.row]
    print(f"board: {a.out} (rows top to bottom; first cell = the style's poster, then the stills, palette strip below) · "
          f"show the user: {os.path.abspath(a.out)} (Claude Code: SendUserFile with display 'render', it opens big in the "
          f"side panel; elsewhere the page {page(a.out, caps)} in your message)" + small_copy(a.out)
          + (" · opened on the user's screen" if show(a.out) else ""))

def small_copy(board):
    """A board of many rows passes a megapixel: the user sees it whole, the agent reads a small copy of it."""
    size = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height",
                           "-of", "csv=p=0:s=x", board], capture_output=True, text=True).stdout.strip()
    try:
        w, h = (int(x) for x in size.split("x"))
    except ValueError:
        return ""
    if w * h <= 900_000:
        return ""
    small = os.path.splitext(board)[0] + "-small.jpg"
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", board, "-vf", f"scale={int(w * (900_000 / (w * h)) ** 0.5) // 2 * 2}:-2",
                    "-q:v", "4", small], check=True)
    return f" · to look at it yourself, read {small}"

def expand(paths):
    """Wildcards are opened here: bash expands refs/*.* before Python sees it, PowerShell passes it as it is."""
    out = []
    for p in paths:
        hits = sorted(glob.glob(p)) if any(c in p for c in "*?[") else [p]
        if not hits:
            raise SystemExit(f"nothing matches {p}")
        out += hits
    return out

def cmd_measure(a):
    paths = [p for p, _ in stills([d for d in (a.dirs or "").split(",") if d])] + expand(a.images)
    if a.pick:
        keep = {int(x) for x in a.pick.split(",")}
        paths = [p for i, p in enumerate(paths, 1) if i in keep]
    if not paths:
        raise SystemExit("measure: no images (--dirs search folders and/or image paths)")
    ms, read = [], []
    for p in paths:                                   # a note or a PDF beside the pictures is passed over, not fatal
        try:
            ms.append(measure_one(p))
            read.append(p)
        except SystemExit as e:
            print(f"passed over: {e}")
    if not ms:
        raise SystemExit("measure: none of these is a picture ffprobe can read")
    paths = read
    P = pool(ms)
    patch = look_patch(P)
    per = [{k: round(m[k], 3) for k in ("p50", "contrast", "chroma", "warmth", "dark")} for m in ms]
    os.makedirs(a.out, exist_ok=True)
    out = {"from": paths, "measured": {k: (round(v, 3) if isinstance(v, float) else v) for k, v in P.items()},
           "per_still": per, "words": words(P), "look_patch": patch}
    json.dump(out, open(os.path.join(a.out, "direction.json"), "w", encoding="utf-8"), indent=1)
    print(f"{len(paths)} stills · {words(P)}")
    print(f"palette {' '.join(c['hex'] for c in P['palette'])} · L p5/p50/p95 {P['p5']:.0f}/{P['p50']:.0f}/{P['p95']:.0f}")
    print(f"look patch -> {os.path.join(a.out, 'direction.json')}: palette, light (az {patch['light']['azimuthDeg']}°, "
          f"el {patch['light']['elevationDeg']}°, estimate), post contrast {patch['post']['contrast']}, "
          f"saturation {patch['post']['saturation']}")

def cmd_compare(a):
    d = json.load(open(a.against, encoding="utf-8"))
    ref, per = d["measured"], d.get("per_still") or [d["measured"]]
    m = pool([measure_one(a.still)])
    rows = [("key (L p50)", "p50", 1, 8), ("contrast", "contrast", 1, 12), ("saturation", "chroma", 1, 6),
            ("warmth (b*)", "warmth", 1, 5), ("dark share", "dark", 100, 12)]
    off = 0
    for name, k, scale, tol in rows:                 # inside the span of the direction's stills, with a tolerance
        v, lo, hi = m[k] * scale, min(x[k] for x in per) * scale - tol, max(x[k] for x in per) * scale + tol
        flag = "ok" if lo <= v <= hi else ("higher" if v > hi else "lower")
        off += flag != "ok"
        print(f"  {name:<12} still {v:6.1f} · direction {lo + tol:6.1f}…{hi - tol:.1f} · {flag}")
    print(f"  still: {words(m)}")
    print(f"  direction: {words(ref) if 'key' in ref else ''}")
    print("  MATCH" if off == 0 else f"  {off} measure(s) off: adjust the look, or name it as intentional")

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    p = sub.add_parser("board")
    p.add_argument("--row", action="append", required=True, help='"A|label|style-id|search-dir,search-dir"')
    p.add_argument("--out", default="review/directions.jpg")
    p.set_defaults(fn=cmd_board)
    p = sub.add_parser("measure")
    p.add_argument("images", nargs="*")
    p.add_argument("--dirs", help="search folders (results.json + thumbs/)")
    p.add_argument("--pick", help="numbers of the stills to use, in the order listed (default: all)")
    p.add_argument("--out", default="research/direction")
    p.set_defaults(fn=cmd_measure)
    p = sub.add_parser("compare")
    p.add_argument("still")
    p.add_argument("--against", required=True)
    p.set_defaults(fn=cmd_compare)
    a = ap.parse_args()
    a.fn(a)

if __name__ == "__main__":
    if os.name == "nt" and not sys.flags.utf8_mode:   # Windows reads, writes and prints in a legacy code page
        import subprocess
        sys.exit(subprocess.call([sys.executable] + sys.argv, env=dict(os.environ, PYTHONUTF8="1")))
    for _s in (sys.stdin, sys.stdout, sys.stderr):
        getattr(_s, "reconfigure", lambda **k: None)(encoding="utf-8", errors="replace")
    sys.exit(main())
```

## File: scripts/fonts.py
size: 17812
sha: d0f594a24b25940486512aad4b1bc41b33095c8b
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/fonts.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/fonts.py

## File: scripts/footage_cut.py
size: 13223
sha: 7e37f28e85e89093f2d55b4509bf27f474e8b243
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/footage_cut.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/footage_cut.py

## File: scripts/formats.py
size: 10179
sha: 1b7bb14747a6e951bb86c9ef16bfb84341667457
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/formats.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/formats.py

## File: scripts/guard.py
size: 103011
sha: deffb467f688115ff48f229cbf34710b043d9e78
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/guard.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/guard.py

## File: scripts/handoff.py
size: 4681
sha: 48a64ae8ec5eceeb2a6c6ed7db4e316cde9d101e
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/handoff.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/handoff.py

## File: scripts/hook.cjs
size: 2894
sha: 56a6a58f7d3df57611be6bc096316ae99f87f1c5
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/hook.cjs

```text
// Runs guard.py for a Claude Code hook. Claude Code starts `node -e <finder> <mode>` with no shell in between (exec
// form), so the hook works the same on macOS, Linux and Windows, with or without Git Bash; the finder in SKILL.md
// loads this file. It never blocks the user over its own trouble: no Python yet means the guard stays out of the way.
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const cp = require('child_process');

const CACHE = process.env.CINECODE_PYTHON_CACHE || path.join(os.homedir(), '.cinecode', 'python.txt');
const PROBE = 'import sys; assert sys.version_info >= (3, 9); print(sys.executable)';

function probe(cmd, args) {
  // Windows' python.exe in WindowsApps is often the Microsoft Store's stub: it prints a hint and exits 9009
  const r = cp.spawnSync(cmd, args.concat(['-c', PROBE]), { encoding: 'utf8', windowsHide: true, timeout: 20000 });
  const exe = r.status === 0 ? (r.stdout || '').trim().split(/\r?\n/).pop() : '';
  return exe && fs.existsSync(exe) ? exe : '';
}

function python() {
  if (process.env.CINECODE_PYTHON) return process.env.CINECODE_PYTHON;
  try {
    const exe = fs.readFileSync(CACHE, 'utf8').trim();
    if (exe && fs.existsSync(exe)) return exe;
  } catch (e) { /* not found yet */ }
  const tries = process.platform === 'win32'
    ? [['py', ['-3']], ['python', []], ['python3', []]]        // py comes with python.org's installer even off PATH
    : [['python3', []], ['python', []]];
  for (const [cmd, args] of tries) {
    const exe = probe(cmd, args);
    if (!exe) continue;
    try {
      fs.mkdirSync(path.dirname(CACHE), { recursive: true });
      fs.writeFileSync(CACHE, exe + '\n');
    } catch (e) { /* a read-only home only costs the next hook a probe */ }
    return exe;
  }
  return '';
}

module.exports = function run(mode) {
  let input;
  try { input = fs.readFileSync(0); } catch (e) { input = Buffer.alloc(0); }
  const guard = (exe) => cp.spawnSync(exe, [path.join(__dirname, 'guard.py'), mode], {
    input,
    stdio: ['pipe', 'pipe', 'inherit'],
    windowsHide: true,
    env: Object.assign({}, process.env, { PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8' }),
  });
  const exe = python();
  if (!exe) process.exit(0);
  let r = guard(exe);
  if (r.error || (r.status !== null && r.status !== 0 && r.status !== 2)) {
    // the guard answers 0 or 2; anything else is the remembered Python gone or broken (an uninstall, the Store's
    // stub in its place): forget it and search once more
    try { fs.unlinkSync(CACHE); } catch (e) { /* already gone */ }
    const again = python();
    if (!again || again === exe) process.exit(0);
    r = guard(again);
  }
  if (r.status !== 0 && r.status !== 2) process.exit(0);
  try { if (r.stdout && r.stdout.length) fs.writeSync(1, r.stdout); } catch (e) { /* nobody reads it */ }
  process.exit(r.status);
};
```

## File: scripts/init_film.py
size: 27124
sha: 63e3d6f107eb4d38d0c6640f2ebb6abf9de5e8fc
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/init_film.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/init_film.py

## File: scripts/keys.py
size: 6937
sha: 306ae2937c174aac49b9973e946371346f9b1136
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/keys.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/keys.py

## File: scripts/lyrics_align.py
size: 7473
sha: 1bcba4deea31d8d6f70ea0e3de1cad8c107fa016
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/lyrics_align.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/lyrics_align.py

## File: scripts/make.py
size: 106559
sha: c98782d29b2a86dc888fbbfff2ea413bdb4c60b9
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/make.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/make.py

## File: scripts/master_audio.py
size: 8218
sha: fe8459e6e9242b483036a3843887e8b1b84f480c
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/master_audio.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/master_audio.py

## File: scripts/mix.py
size: 7223
sha: 2261e8a8e8291fab45e48f969398bd4f3526e9f8
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/mix.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/mix.py

## File: scripts/moves.py
size: 10785
sha: 056ec2fc94fb3191f7a613eb5a28c7f21c1ca808
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/moves.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/moves.py

## File: scripts/qc.py
size: 16238
sha: 3e763bb38d0f9d391c122738964e54330e49648d
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/qc.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/qc.py

## File: scripts/ref_video.py
size: 11034
sha: 0f0419f1869f2115573703400f477e8e16fe9c69
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/ref_video.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/ref_video.py

## File: scripts/samples.py
size: 23836
sha: 9f8c20a9147f43333b3bc7a1052a949d1a8123a2
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/samples.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/samples.py

## File: scripts/scene_check.py
size: 18593
sha: 37fc632a7ce91ea74f8e99f4236444b13209d7b9
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/scene_check.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/scene_check.py

## File: scripts/seeds.json
size: 168792
sha: e49075d85930cad8c3a4ec086885b047b989ed66
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/seeds.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/seeds.json

## File: scripts/snapshot.py
size: 3122
sha: 7e30940ac3062b18279e926af6e328d37e9c3eb7
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/snapshot.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/snapshot.py

## File: scripts/sounds.py
size: 12307
sha: b611d8bd1858493ab35cce75dfa2e9e1c04e9897
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/sounds.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/sounds.py

## File: scripts/sparks.json
size: 21854
sha: 71e2cadccadf044d7a6e17495b7dd1a70b669d27
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/sparks.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/sparks.json

## File: scripts/styles.py
size: 24401
sha: 716c7ffa3d74c15ae83c4899cf1a87e9cb065a4e
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/styles.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/styles.py

## File: scripts/tts.py
size: 20775
sha: 9023a450aa147596a0efe5933cd94c1e7b9ca058
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/tts.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/tts.py

## File: scripts/type_deal.py
size: 23790
sha: 13a38115e0551d47853cbcfd3ed6c0128454ce8b
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/type_deal.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/type_deal.py

## File: scripts/type_extract.py
size: 13186
sha: e2b24d50faadc6c0ab1de7d14e9c92893b30a244
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/type_extract.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/type_extract.py

## File: scripts/type_refs.py
size: 17604
sha: 75f419b56f04235ba52fd4f5696a252e2f4fa5fe
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/type_refs.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/type_refs.py

## File: scripts/usage.py
size: 9247
sha: 43c148bd3fc645b7c9a7f3acfbc2f4e93f42db57
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/usage.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/usage.py

## File: scripts/validate_shots.py
size: 15771
sha: 9485d54c2a5502a23062cb7f51cca4bce9ddaf90
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/validate_shots.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/validate_shots.py

## File: scripts/voice_treat.py
size: 10801
sha: 859389316e68141e875cacc368001b32199d3c7a
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/voice_treat.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/voice_treat.py

## File: scripts/words_check.py
size: 10958
sha: 3b5779ec87a8d6d9b370bcee93f5b72594972467
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/words_check.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/scripts/words_check.py

## File: references/assets-and-licences.md
size: 8173
sha: 170191c6eaa61215c066000df284915120080098
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/assets-and-licences.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/assets-and-licences.md

## File: references/camera-moves.md
size: 12217
sha: 72e135d47b7042324ea5fbb95020b9f1bce418b6
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/camera-moves.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/camera-moves.md

## File: references/characters.md
size: 3186
sha: ce9e6c2984d15f471c52702f7596f6305ab569ea
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/characters.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/characters.md

## File: references/context-protocol.md
size: 12156
sha: 3000b6935a7ed80eba8369200618411dadc4301e
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/context-protocol.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/context-protocol.md

## File: references/craft.md
size: 13908
sha: 2950099d27b2542c9503459a0a9599a2dd99f4bf
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/craft.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/craft.md

## File: references/engine.md
size: 11012
sha: 4c4417c4aacc6be7a68090d35f2252809bb04fac
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/engine.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/engine.md

## File: references/film-mode.md
size: 30411
sha: 1d640c9f47df81e9cb4026af163c64001eaa838f
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/film-mode.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/film-mode.md

## File: references/finish.md
size: 4539
sha: f2a385849f3933562e7bd13e69cd81ad5ad2f92b
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/finish.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/finish.md

## File: references/footage-edit.md
size: 5799
sha: 5f35bcd3e83c02cb0d51c9689337968fd76046df
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/footage-edit.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/footage-edit.md

## File: references/jury.md
size: 1818
sha: 7ee455a84e71f709e2349262de22ed88da52817f
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/jury.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/jury.md

## File: references/looks.md
size: 31981
sha: 2167e5475b6348d990ce1bab1bd019cc74e6349a
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/looks.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/looks.md

## File: references/motion-design.md
size: 3575
sha: dc30aafb489baeb8a538d51be43a9d8f0dccc629
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/motion-design.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/motion-design.md

## File: references/motion.md
size: 10985
sha: 33cac79c9825a6c37326a689a903506b19f547de
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/motion.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/motion.md

## File: references/music-video.md
size: 4867
sha: 74afba27a992a0e8deea571fd86d4e8989511c58
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/music-video.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/music-video.md

## File: references/purpose-ad.md
size: 13259
sha: c7761e1fb0409bb8cec1165d35c20220eb4f3766
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/purpose-ad.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/purpose-ad.md

## File: references/purpose-cinematic.md
size: 8633
sha: 930b323cfdbe2863ac6ebc3ae2402336d86ce2e4
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/purpose-cinematic.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/purpose-cinematic.md

## File: references/quick-mode.md
size: 56856
sha: 39cbf1db64af6b2d1b93def2b615d7ca66a56ea7
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/quick-mode.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/quick-mode.md

## File: references/reference-video.md
size: 3384
sha: f61af20ab1afe496857738a2b175b070d17c0690
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/reference-video.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/reference-video.md

## File: references/sound.md
size: 31380
sha: 59a622113576890ccb34b47fab0f2b11a03e50ed
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/sound.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/sound.md

## File: references/steering.md
size: 1545
sha: 8ee4a67bc0f340de9722367cd24d05d43970b456
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/steering.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/steering.md

## File: references/type-library.json
size: 172857
sha: 2da28e08022afff11094f13ff6af21ee00d51cd8
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/type-library.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/type-library.json

## File: references/typography.md
size: 23562
sha: 51136f7acf6356c999fa36aee4814dd05db3e727
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/typography.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/typography.md

## File: references/writer-prompt.md
size: 3364
sha: c7bfef1b79f25ddc9da8a23679473788a17c144f
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/writer-prompt.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/writer-prompt.md

## File: references/writing.md
size: 24137
sha: 903493be38146ff574bd2e38cc0ce791f3f5bb66
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/references/writing.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/references/writing.md

## File: .github/workflows/tests.yml
size: 3351
sha: 7c987a21e73c142cf970b9b935a3c89d055a54f6
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/.github/workflows/tests.yml

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/.github/workflows/tests.yml

## File: .gitignore
size: 276
sha: dc4be3360eab3b9e78c7720bc3c1c0f9c758cc38
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/.gitignore

```text
# Film projects made inside a clone of this repo stay out of git.
films/

# Media and render output
*.mp4
*.mov
*.webm
*.wav
*.aiff
*.flac
*.mp3
*.ogg
frames/
renders/
research/.cache/

# Secrets
.env
.env.*

# OS / editor
.DS_Store
Thumbs.db
node_modules/
__pycache__/
*.pyc
```

## File: AGENTS.md
size: 1004
sha: 58f0265f03b4668f3290c8068b72de6c40098ce8
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/AGENTS.md

````text
# cinecode

This repository is an agent skill for making short films with code.

- **Making a film:** when the user asks for a short film (for example "kısa film yapmak istiyorum", "make a short film with code"), read `./SKILL.md` and follow it (it starts with two questions: the mode and the purpose). The kit folder is the folder this file is in; create the film in a separate, empty project folder, never inside this repository.
- **Editing the kit itself:** keep the docs generic and positive (no personal paths or project names), keep scripts dependency-free (Node 22+ built-ins, Python 3 standard library, ffmpeg), keep `SKILL.md` frontmatter valid (`name` matches the folder name `cinecode`), and update both `README.md` (Turkish, the default page) and `README.en.md` when behaviour changes.

To use the skill from another project, add this to that project's `AGENTS.md` (adjust the path):

```markdown
When the user asks for a short film, read and follow ~/.cursor/skills/cinecode/SKILL.md.
```
````

## File: docs/media/lockup.png
size: 16116
sha: 400d5a57ece0d93637a5601edc5492e792182ae7
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/lockup.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/lockup.png

## File: docs/media/reel.gif
size: 11686272
sha: a95787b5a615309b0cd3f503e4d3f18a3e523bea
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/reel.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/reel.gif

## File: docs/media/stills/atlama.jpg
size: 179875
sha: 91a437026cbf58aa3e7c721c6aabf4106c838b81
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/atlama.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/atlama.jpg

## File: docs/media/stills/cinecode.jpg
size: 200999
sha: 964116751930f3e2d1df4d8cb0a8c79fd71f2305
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/cinecode.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/cinecode.jpg

## File: docs/media/stills/fener.jpg
size: 25080
sha: c43931273860fd3033c9dd82c6d5f2a3f172dc37
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/fener.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/fener.jpg

## File: docs/media/stills/firin.jpg
size: 54300
sha: c76e92637c89a016f0736a93aa9e5532cadf7ce5
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/firin.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/firin.jpg

## File: docs/media/stills/gokyuzu.jpg
size: 44475
sha: 6f84953670b0907a9271e01f860c224e8d88c1ff
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/gokyuzu.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/gokyuzu.jpg

## File: docs/media/stills/ilktik.jpg
size: 75688
sha: 29ef4b947c3f1dc09066f5889221fd8e91295bff
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/ilktik.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/ilktik.jpg

## File: docs/media/stills/kibrit.jpg
size: 171962
sha: d95829d9b6e5f2d49e1ff0a1eb23d3ff8f32a084
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/kibrit.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/kibrit.jpg

## File: docs/media/stills/koltuk.jpg
size: 58490
sha: eed8fdcd817cb935fe12ec5d58d883b7f9f386de
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/koltuk.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/koltuk.jpg

## File: docs/media/stills/kopek.jpg
size: 56673
sha: 873047fdb49d09777aa3461fe9046f5212b45378
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/kopek.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/kopek.jpg

## File: docs/media/stills/motor.jpg
size: 131124
sha: 69ef0bf99ce279903106c44e01b6fbfbbab98eea
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/motor.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/motor.jpg

## File: docs/media/stills/titanik.jpg
size: 69822
sha: 47fae9afa366a0681d926a425a39491cfb3037ea
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/titanik.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/titanik.jpg

## File: docs/media/stills/tren.jpg
size: 126931
sha: 71ebbd69ed47661fa4cc814306210ede9b560314
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/tren.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/media/stills/tren.jpg

## File: docs/type-library.md
size: 49994
sha: c76da9d2a9ddcff81d1a5c3b94e2647f26be6704
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/docs/type-library.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/docs/type-library.md

## File: LICENSE
size: 1082
sha: a83c0fb81d768b6cc066891de01e5fc79159cd3d
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/LICENSE

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/LICENSE

## File: README.en.md
size: 41166
sha: db253f108c7cf79484815e29d7f002e6e16a40f6
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/README.en.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/README.en.md

## File: README.ja.md
size: 59427
sha: dcbe6d411fc017cc7887ed78a88b429da3d959f9
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/README.ja.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/README.ja.md

## File: README.zh-CN.md
size: 43421
sha: 51686b0e2e39de32aa514117b5d4c2adfc193fc7
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/README.zh-CN.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/README.zh-CN.md

## File: starter/camera.mjs
size: 7173
sha: 7c68d1cf885657736eb2dd788d5b281c56e6bdd9
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/starter/camera.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/starter/camera.mjs

## File: starter/index.html
size: 12115
sha: 3003af74fa978d602cbdfb7004a00afed453b351
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/starter/index.html

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/starter/index.html

## File: starter/looks.mjs
size: 17592
sha: ca34efbf94e3529f5811f4976148d9a4c7542ccd
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/starter/looks.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/starter/looks.mjs

## File: starter/scene.html
size: 9580
sha: 221481b14b52e18478b04a0ae5c72f230d7695e0
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/starter/scene.html

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/starter/scene.html

## File: starter/type.mjs
size: 17403
sha: 38caf4f35940ab249f921638087a5766a557415d
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/starter/type.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/starter/type.mjs

## File: styles/aliases.json
size: 7105
sha: afdb1cbe045d48cea55f76d6020cc3583528d024
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/aliases.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/aliases.json

## File: styles/archive-lighttable.md
size: 13452
sha: bb11c6276eb862b08651b88549ef94549b2bd9ed
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/archive-lighttable.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/archive-lighttable.md

## File: styles/art-deco.md
size: 12300
sha: 00d8e969500207326aab987188df971f3d6d066b
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/art-deco.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/art-deco.md

## File: styles/ascii-crt.md
size: 12412
sha: 0e0d73bda3f6da3df845d27ddb6365d9edbd7f31
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/ascii-crt.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/ascii-crt.md

## File: styles/backrooms.md
size: 12755
sha: b5d32cdc9b9fdca630ac4e401016770f0e4e29e8
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/backrooms.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/backrooms.md

## File: styles/blueprint.md
size: 12533
sha: e24479d48b643b07dd33683ca5273c5bd0377224
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/blueprint.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/blueprint.md

## File: styles/brick-toy.md
size: 12656
sha: 4076c32b24028fe7476ec5582feeef55232016a1
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/brick-toy.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/brick-toy.md

## File: styles/briefing-board.md
size: 13567
sha: 5cf4cecbc34d3d8d7c40490f4a809abcff163c65
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/briefing-board.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/briefing-board.md

## File: styles/cel-anime-80s.md
size: 12742
sha: 492e4fa547404f2f37604eb8dffd226d139bc474
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/cel-anime-80s.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/cel-anime-80s.md

## File: styles/crayon-book.md
size: 11553
sha: a66200f3cc6e9355507edef81a81eb8cb99e25c9
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/crayon-book.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/crayon-book.md

## File: styles/dark-keynote.md
size: 12380
sha: 7f506ea56e4020c826395e17dfe2170854a96cfd
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/dark-keynote.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/dark-keynote.md

## File: styles/data-physical.md
size: 13584
sha: 9b6ba174f9e67652cb3b9d5b84c413d1280a2aa8
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/data-physical.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/data-physical.md

## File: styles/dataviz.md
size: 11621
sha: b4cbe05e68d0b31518c0e3a073f5757bf4250a3f
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/dataviz.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/dataviz.md

## File: styles/engraving.md
size: 12443
sha: 47dc532c3e5378668c383ccf6eee3616679f35a1
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/engraving.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/engraving.md

## File: styles/game-show.md
size: 12679
sha: c2c2b13dc075cb631071a71f0075ffe707d6eb41
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/game-show.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/game-show.md

## File: styles/glass-product.md
size: 12308
sha: 407e31faf7f19a24632f8f59f74bbb6f4cbb2064
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/glass-product.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/glass-product.md

## File: styles/halftone-dossier.md
size: 12614
sha: c61cd08ca08a3863f3aa4e95fa088b5ff2d0b67e
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/halftone-dossier.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/halftone-dossier.md

## File: styles/hd-2d.md
size: 12275
sha: d4ca41c60231e03a6424d9a0cd5512049c14b0fc
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/hd-2d.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/hd-2d.md

## File: styles/hologram-hud.md
size: 12049
sha: 0159fee61efc0f6fb9c811dca900cdd3efc27c39
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/hologram-hud.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/hologram-hud.md

## File: styles/impasto.md
size: 12116
sha: 14ba98e7dfa16967197b23afe6722995f89f73e1
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/impasto.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/impasto.md

## File: styles/index.json
size: 40723
sha: e59ea1497cd8690079cc8b96fa7d0b1a9a9ca17f
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/index.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/index.json

## File: styles/ink-wash.md
size: 11381
sha: 42ed6c51ba611b3e425df4e6c93504ee78daf12b
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/ink-wash.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/ink-wash.md

## File: styles/iso-infographic.md
size: 12196
sha: 0469fccfd89884b5bbde4a0e61946ca5aaf15341
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/iso-infographic.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/iso-infographic.md

## File: styles/LICENSE-lemo-opuscar
size: 1064
sha: 5ba3cce5467fd31494d7e117662de08f477e372a
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/LICENSE-lemo-opuscar

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/LICENSE-lemo-opuscar

## File: styles/lidar-survey.md
size: 13262
sha: 81df468cc1fbb4fdd4534b1bc954bc5b2ca94d57
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/lidar-survey.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/lidar-survey.md

## File: styles/line-boil.md
size: 4222
sha: 34150cdbf468071c64904afcc6878a05175f2e48
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/line-boil.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/line-boil.md

## File: styles/liquid-motion.md
size: 3853
sha: 52eada20aad3f77da9cac510a13ba6d0126450db
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/liquid-motion.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/liquid-motion.md

## File: styles/living-screencast.md
size: 11724
sha: a521033cfc3342e2b78661773cb13316b51cdfe2
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/living-screencast.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/living-screencast.md

## File: styles/lowpoly-island.md
size: 12412
sha: e81d0e86989c26923432a536b37ac24fbeafe447
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/lowpoly-island.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/lowpoly-island.md

## File: styles/microgame.md
size: 12766
sha: ee82d3c56c4d2cf2b35a6d4bc19e239450c377ab
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/microgame.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/microgame.md

## File: styles/microscope.md
size: 13259
sha: 7967d648695e2cc5b6858ebc4cd1ad9bdd24e4b8
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/microscope.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/microscope.md

## File: styles/midcentury-toon.md
size: 12842
sha: be7cdfbf2d7448762bdadfc8883d4562c19b061f
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/midcentury-toon.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/midcentury-toon.md

## File: styles/one-line.md
size: 11580
sha: 1058a642951d087e8cb1cbd420739a91139f82b0
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/one-line.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/one-line.md

## File: styles/paper-lantern.md
size: 12338
sha: 9160908d86d826d3a67a15d73349f1588f2a0a1d
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/paper-lantern.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/paper-lantern.md

## File: styles/paper-popup.md
size: 12202
sha: da3e636a65e00be157d19502053ab46f7d9bbab8
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/paper-popup.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/paper-popup.md

## File: styles/papercut-red.md
size: 12544
sha: b54779afa3abbbace8113dbe8e13d81d59b4e176
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/papercut-red.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/papercut-red.md

## File: styles/pictogram-motion.md
size: 12309
sha: 3ca9a8dfb62de75dc4692a2a6d61d7fd668c0325
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/pictogram-motion.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/pictogram-motion.md

## File: styles/pixel-rpg.md
size: 11488
sha: d47e4bffdea556ae51a6c8f97cb3370b822b03fb
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/pixel-rpg.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/pixel-rpg.md

## File: styles/risograph.md
size: 12135
sha: 162033051e4dca1303208c43526aa1771131b747
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/risograph.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/risograph.md

## File: styles/rubber-hose.md
size: 13005
sha: eb2bc95700cd0d6d1fc2d4d44fd624a256703418
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/rubber-hose.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/rubber-hose.md

## File: styles/satellite-briefing.md
size: 13288
sha: 0dfdbc0bb8e940b12eb6e6ec2da81d246c1cd69a
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/satellite-briefing.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/satellite-briefing.md

## File: styles/scifi-toon.md
size: 12861
sha: 147e5d4eb9872a4fc590a5318455dacc19f9b751
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/scifi-toon.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/scifi-toon.md

## File: styles/shadow-puppet.md
size: 12896
sha: 2d6fcd15eba0308b870c70d4cf4c34b14b675627
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/shadow-puppet.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/shadow-puppet.md

## File: styles/shape-morph.md
size: 3397
sha: e7e7a7811f9eaf138b2c1b66d5db7f563c0686f8
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/shape-morph.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/shape-morph.md

## File: styles/silent-film.md
size: 11799
sha: d537cc6986a97cd60820d339ac28b05918b67711
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/silent-film.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/silent-film.md

## File: styles/silkscreen-poster.md
size: 13233
sha: 463455b08650dbc5450977cb837eebdb8957ab32
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/silkscreen-poster.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/silkscreen-poster.md

## File: styles/spy-titles.md
size: 11623
sha: efb09611ab6d2943211235b29975547c5c1f4d43
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/spy-titles.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/spy-titles.md

## File: styles/stained-glass.md
size: 12033
sha: 97d6143a6e7505fc55685f32e1cb3813a25e7569
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/stained-glass.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/stained-glass.md

## File: styles/sticker-explainer.md
size: 3358
sha: 61832d9561db0d261890c191974390759dcf212d
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/sticker-explainer.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/sticker-explainer.md

## File: styles/swiss-motion.md
size: 10912
sha: d98c28e5bd0c3ce61fd113bb77dde9761174a83d
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/swiss-motion.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/swiss-motion.md

## File: styles/teardown.md
size: 13676
sha: 36096b14e9ee272a66778d60a16e2cee40fe6dd2
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/teardown.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/teardown.md

## File: styles/tilt-shift.md
size: 12183
sha: 52e451a94e3a52cd6ca4a13f451a4e60497a2328
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/tilt-shift.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/tilt-shift.md

## File: styles/ukiyoe.md
size: 12763
sha: fc31b7feaa5a291ac812d672a2aed17085059120
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/ukiyoe.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/ukiyoe.md

## File: styles/urban-sketch.md
size: 11982
sha: 728f734ce7caf2217f73e9e1f4096a6893af3d6e
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/urban-sketch.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/urban-sketch.md

## File: styles/variety-captions.md
size: 3728
sha: c6e42dc28babbe793a4ed67051a79b1e2bf9fbd7
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/variety-captions.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/variety-captions.md

## File: styles/visual-proof.md
size: 4827
sha: 4f98eac97c60d7ef44b5c2e97c70ea1118b536ca
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/visual-proof.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/visual-proof.md

## File: styles/vox-explainer.md
size: 7201
sha: b608cbb031632363d3906eccf3b2d9eab068407e
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/vox-explainer.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/vox-explainer.md

## File: styles/watercolor.md
size: 11311
sha: b2e227ff404720474436d5b0d215324253a2f23f
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/watercolor.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/watercolor.md

## File: styles/white-model.md
size: 13688
sha: 16f6b90c589fa309e4a360f8eaaeded2e3999196
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/white-model.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/white-model.md

## File: styles/whiteboard.md
size: 11860
sha: 4439f1be18d2c89b920dc63c0f14dbbc1cab4923
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/whiteboard.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/whiteboard.md

## File: styles/woodcut.md
size: 12377
sha: d2472c569ab93a520c26bb5cf70d6993d4eef1e3
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/styles/woodcut.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/styles/woodcut.md

## File: templates/HANDOFF.md
size: 2909
sha: 5cbcd3705e90e679404411c9cda39b39625e0ee2
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/HANDOFF.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/HANDOFF.md

## File: templates/INTERVIEW.md
size: 11977
sha: 6a51795cc3c517cf411244d73524bd762f90fd5c
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/INTERVIEW.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/INTERVIEW.md

## File: templates/LICENSES.csv
size: 682
sha: a09e594cef1d38ebdcd11955f4e67f1d008af0c3
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/LICENSES.csv

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/LICENSES.csv

## File: templates/quick/film.html
size: 116794
sha: 4a2117e0c70f8cddd794b0a040339d563836b271
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/quick/film.html

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/quick/film.html

## File: templates/quick/HELPERS.md
size: 16878
sha: 7de2d9502e7014e036c7b80c516fb6b16009a095
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/quick/HELPERS.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/quick/HELPERS.md

## File: templates/quick/REVIEWER.md
size: 3705
sha: 4194c82381ecb74ea6e4c41aaa2651eee202d2d0
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/quick/REVIEWER.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/quick/REVIEWER.md

## File: templates/quick/STATE.md
size: 3804
sha: de72021c8264b1ef976c45d7fb0f1d32710f362c
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/quick/STATE.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/quick/STATE.md

## File: templates/ROADMAP.md
size: 5338
sha: 0d845367322c90d02b298506076f21ff1a8345a3
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/ROADMAP.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/ROADMAP.md

## File: templates/SCRIPT.md
size: 2137
sha: 8b51e27952f735e30b30ce304937034c21cd6eb3
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/SCRIPT.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/SCRIPT.md

## File: templates/shots.example.json
size: 5797
sha: 36a3a3250c4479900d2cd7e9a67569709790295c
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/shots.example.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/shots.example.json

## File: templates/STATE.md
size: 3316
sha: 349f3b1350fdbaa9adc67499d1099ad1de605d91
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/STATE.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/STATE.md

## File: templates/type-specimen.html
size: 11528
sha: 33dcacbbeca2846c68c17d1aeb94fe52e6778e8e
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/templates/type-specimen.html

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/templates/type-specimen.html

## File: tests/test_capture.py
size: 16448
sha: bed2f9ff08d48c456678c8e7285aa38fac4f8d24
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/tests/test_capture.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/tests/test_capture.py

## File: tests/test_context.py
size: 266065
sha: eab3cf8b4545b12b859201847cc68930c2a13c9f
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/tests/test_context.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/tests/test_context.py

## File: tests/test_tools.py
size: 23237
sha: 0760823b863d84ecf1d4b66343c5ff999a04a4b9
upstream: https://github.com/sarpstar/cinecode/blob/HEAD/tests/test_tools.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/sarpstar/cinecode/blob/HEAD/tests/test_tools.py
