⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
cinecode
>-
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
Not yet listed on ClawHub or SkillsMP
// README
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
Nasıl çalışır
- Fikri söylersin. Bir cümle yeter; eksiksiz bir brief doğrudan işe koyulur.
- 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.
- 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.
- 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
// HOW IT'S BUILT
// FILES
168 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
SKILL.md32 KB91093176a552truncatedupstream ↗
Showing the inlined prefix. The rest is in the upstream file.
--- 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 withREADME.md43 KB79170ad1923eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/animatic.py11 KB5c62f3a75d3bupstream ↗
#!/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()scripts/asset_search.py55 KB7ddfd189a3d3upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/beat_grid.py9.5 KB540af53ee409upstream ↗
#!/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())scripts/brief.py14 KB543295072045upstream ↗
#!/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())scripts/captions.py6.1 KBea2ba7faf28bupstream ↗
#!/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/[email protected] audio/vo/[email protected] --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())scripts/capture.mjs29 KB4be2d544eb2dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/deal.py16 KBc4bd9030a786upstream ↗
#!/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()scripts/dice.py15 KBe9c4ffc182e4upstream ↗
#!/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¢er=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)scripts/direction.py23 KBae87d85b3e96upstream ↗
#!/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())scripts/fonts.py17 KBd0f594a24b25upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/footage_cut.py13 KB7e37f28e85e8upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/formats.py9.9 KB1b7bb14747a6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/guard.py101 KBdeffb467f688upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/handoff.py4.6 KB48a64ae8ec5eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/hook.cjs2.8 KB56a6a58f7d3dupstream ↗
// 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); };scripts/init_film.py26 KB63e3d6f107ebupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/keys.py6.8 KB306ae2937c17upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/lyrics_align.py7.3 KB1bcba4deea31upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/make.py104 KBc98782d29b2aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/master_audio.py8.0 KBfe8459e6e924upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/mix.py7.1 KB2261e8a8e829upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/moves.py11 KB056ec2fc94fbupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/qc.py16 KB3e763bb38d0fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/ref_video.py11 KB0f0419f1869fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/samples.py23 KB9f8c20a9147fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/scene_check.py18 KB37fc632a7ce9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/seeds.json165 KBe49075d85930upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/snapshot.py3.0 KB7e30940ac306upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/sounds.py12 KBb611d8bd1858upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/sparks.json21 KB71e2cadccadfupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/styles.py24 KB716c7ffa3d74upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/tts.py20 KB9023a450aa14upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/type_deal.py23 KB13a38115e055upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/type_extract.py13 KBe2b24d50faadupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/type_refs.py17 KB75f419b56f04upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/usage.py9.0 KB43c148bd3fc6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/validate_shots.py15 KB9485d54c2a55upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/voice_treat.py11 KB859389316e68upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
scripts/words_check.py11 KB3b5779ec87a8upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/assets-and-licences.md8.0 KB170191c6eaa6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/camera-moves.md12 KB72e135d47b70upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/characters.md3.1 KBce9e6c2984d1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/context-protocol.md12 KB3000b6935a7eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/craft.md14 KB2950099d27b2upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/engine.md11 KB4c4417c4aaccupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/film-mode.md30 KB1d640c9f47dfupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/finish.md4.4 KBf2a385849f39upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/footage-edit.md5.7 KB5f35bcd3e83cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/jury.md1.8 KB7ee455a84e71upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/looks.md31 KB2167e5475b63upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/motion-design.md3.5 KBdc30aafb489bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/motion.md11 KB33cac79c9825upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/music-video.md4.8 KB74afba27a992upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/purpose-ad.md13 KBc7761e1fb040upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/purpose-cinematic.md8.4 KB930b323cfdbeupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/quick-mode.md56 KB39cbf1db64afupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/reference-video.md3.3 KBf61af20ab1afupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/sound.md31 KB59a622113576upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/steering.md1.5 KB8ee4a67bc0f3upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/type-library.json169 KB2da28e08022aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/typography.md23 KB51136f7acf63upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/writer-prompt.md3.3 KBc7bfef1b79f2upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
references/writing.md24 KB903493be3814upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
.github/workflows/tests.yml3.3 KB7c987a21e73cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
.gitignore276 Bdc4be3360eabupstream ↗
# 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__/ *.pycAGENTS.md1004 B58f0265f03b4upstream ↗
# 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. ```docs/media/lockup.png16 KB400d5a57ece0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/reel.gif11.1 MBa95787b5a615upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/atlama.jpg176 KB91a437026cbfupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/cinecode.jpg196 KB964116751930upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/fener.jpg24 KBc43931273860upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/firin.jpg53 KBc76e92637c89upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/gokyuzu.jpg43 KB6f84953670b0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/ilktik.jpg74 KB29ef4b947c3fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/kibrit.jpg168 KBd95829d9b6e5upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/koltuk.jpg57 KBeed8fdcd817cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/kopek.jpg55 KB873047fdb49dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/motor.jpg128 KB69ef0bf99ce2upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/titanik.jpg68 KB47fae9afa366upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/media/stills/tren.jpg124 KB71ebbd69ed47upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/type-library.md49 KBc76da9d2a9ddupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
LICENSE1.1 KBa83c0fb81d76upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
README.en.md40 KBdb253f108c7cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
README.ja.md58 KBdcbe6d411fc0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
README.zh-CN.md42 KB51686b0e2e39upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
starter/camera.mjs7.0 KB7c68d1cf8856upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
starter/index.html12 KB3003af74fa97upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
starter/looks.mjs17 KBca34efbf94e3upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
starter/scene.html9.4 KB221481b14b52upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
starter/type.mjs17 KB38caf4f35940upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/aliases.json6.9 KBafdb1cbe045dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/archive-lighttable.md13 KBbb11c6276eb8upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/art-deco.md12 KB00d8e9695002upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/ascii-crt.md12 KB0e0d73bda3f6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/backrooms.md12 KBb5d32cdc9b9fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/blueprint.md12 KBe24479d48b64upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/brick-toy.md12 KB4076c32b2402upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/briefing-board.md13 KB5cf4cecbc34dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/cel-anime-80s.md12 KB492e4fa54740upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/crayon-book.md11 KBa66200f3cc6eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/dark-keynote.md12 KB7f506ea56e40upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/data-physical.md13 KB9b6ba174f9e6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/dataviz.md11 KBb4cbe05e68d0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/engraving.md12 KB47dc532c3e53upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/game-show.md12 KBc2c2b13dc075upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/glass-product.md12 KB407e31faf7f1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/halftone-dossier.md12 KBc61cd08ca08aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/hd-2d.md12 KBd4ca41c60231upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/hologram-hud.md12 KB0159fee61efcupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/impasto.md12 KB14ba98e7dfa1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/index.json40 KBe59ea1497cd8upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/ink-wash.md11 KB42ed6c51ba61upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/iso-infographic.md12 KB0469fccfd898upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/LICENSE-lemo-opuscar1.0 KB5ba3cce5467fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/lidar-survey.md13 KB81df468cc1fbupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/line-boil.md4.1 KB34150cdbf468upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/liquid-motion.md3.8 KB52eada20aad3upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/living-screencast.md11 KBa521033cfc33upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/lowpoly-island.md12 KBe81d0e86989cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/microgame.md12 KBee82d3c56c4dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/microscope.md13 KB7967d648695eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/midcentury-toon.md13 KBbe7cdfbf2d74upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/one-line.md11 KB1058a642951dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/paper-lantern.md12 KB9160908d86d8upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/paper-popup.md12 KBda3e636a65e0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/papercut-red.md12 KBb54779afa3abupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/pictogram-motion.md12 KB3ca9a8dfb62dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/pixel-rpg.md11 KBd47e4bffdea5upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/risograph.md12 KB162033051e4dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/rubber-hose.md13 KBeb2bc95700cdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/satellite-briefing.md13 KB0dfdbc0bb8e9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/scifi-toon.md13 KB147e5d4eb987upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/shadow-puppet.md13 KB2d6fcd15eba0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/shape-morph.md3.3 KBe7e7a7811f9eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/silent-film.md12 KBd537cc6986a9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/silkscreen-poster.md13 KB463455b08650upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/spy-titles.md11 KBefb09611ab6dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/stained-glass.md12 KB97d6143a6e75upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/sticker-explainer.md3.3 KB61832d9561dbupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/swiss-motion.md11 KBd98c28e5bd0cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/teardown.md13 KB36096b14e9eeupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/tilt-shift.md12 KB52e451a94e3aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/ukiyoe.md12 KBfc31b7feaa5aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/urban-sketch.md12 KB728f734ce7caupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/variety-captions.md3.6 KBc6e42dc28babupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/visual-proof.md4.7 KB4f98eac97c60upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/vox-explainer.md7.0 KBb608cbb03163upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/watercolor.md11 KBb2e227ff4047upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/white-model.md13 KB16f6b90c589fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/whiteboard.md12 KB4439f1be18d2upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
styles/woodcut.md12 KBd2472c569ab9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/HANDOFF.md2.8 KB5cbcd3705e90upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/INTERVIEW.md12 KB6a51795cc3c5upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/LICENSES.csv682 Ba09e594cef1dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/quick/film.html114 KB4a2117e0c70fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/quick/HELPERS.md16 KB7de2d9502e70upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/quick/REVIEWER.md3.6 KB4194c82381ecupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/quick/STATE.md3.7 KBde72021c8264upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/ROADMAP.md5.2 KB0d845367322cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/SCRIPT.md2.1 KB8b51e27952f7upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/shots.example.json5.7 KB36a3a3250c44upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/STATE.md3.2 KB349f3b1350fdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/type-specimen.html11 KB33dcacbbeca2upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
tests/test_capture.py16 KBbed2f9ff08d4upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
tests/test_context.py260 KBeab3cf8b4545upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
tests/test_tools.py23 KB0760823b863dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/sarpstar-cinecode.md