⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
papercut
Make a finished paper-collage documentary short (the Vox-style halftone cut-out look) from one topic, for free, on your own computer. Claude verifies the facts, writes the script, finds public-domain archive photos, and the engine cuts them out, voices the narration, animates every piece in stop-motion, adds paper sound effects and captions, and renders the MP4. Use for "make a collage video about X", "Vox-style explainer", "paper cut-out documentary", "halftone collage short", or any history, tech or true-story short where no footage exists.
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
papercut
Vox-style paper-collage documentaries, made by Claude, for free, on your own computer.
One topic in, one finished vertical short out: halftone cut-outs, torn paper, rubber stamps, maps, typewriter documents, stop-motion on twos, paper sound effects, captions and a voice. No video model, no image model, no credits.

How it works
AI video tools invent the picture, and every clip costs credits. papercut builds the picture instead, from real archive photos and code:
- Claude checks the facts, writes a 10-14 beat script and picks the photos.
- Wikimedia Commons supplies the photos. Only public domain and CC0 by default; every photo is credited automatically.
- rembg removes the background, then the engine prints it as halftone dots on paper, cuts a scissor border, adds a misregistered colour offset and a real shadow.
- Kokoro (an open voice model, runs on the CPU) reads the script. faster-whisper times every word for the captions.
- HyperFrames animates it as HTML: every piece slides, drops or stamps in at 12 poses a second, boils a pixel like a real animation table, and the camera pushes in step by step.
- Paper slides, taps, stamp thuds, tape and a typewriter are synthesised in code, mixed under the voice to -14 LUFS.
Everything that ships in the video is either generated from a seed or free to use. Nothing is rented.
Install
You need Python 3.10+, Node 18+, FFmpeg and git.
git clone https://github.com/basharmokdad/papercut
cd papercut
./setup.sh # macOS / Linux
# or on Windows:
powershell -ExecutionPolicy Bypass -File setup.ps1
Setup makes a Python venv in .venv, installs the libraries, downloads the Kokoro voice model (~330 MB) to ~/.papercut/kokoro, and fetches the map data and animation library into engine/data.
To use it as a Claude Code skill, link or copy the folder into your skills folder:
ln -s "$(pwd)" ~/.claude/skills/papercut
Then ask Claude: "Make a papercut video about the first computer bug."
Use it by hand
mkdir my-film && cd my-film
../.venv/bin/python ../engine/papercut.py search "Grace Hopper" sheet.png # find photos, look at sheet.png
# write story.json (see examples/ and SKILL.md)
../.venv/bin/python ../engine/papercut.py make story.json # build
../.venv/bin/python ../engine/papercut.py snap story.json 3.2,7.5 # check frames
../.venv/bin/python ../engine/papercut.py make story.json --render # render the MP4
Each film is one story.json: the images and a list of beats. A beat is one line of narration plus one scene: hero, photo, duo, number, document, timeline, quote or map. The full reference and the picture rules are in SKILL.md.
Examples
| Film | Scenes it shows off |
|---|---|
| The first computer bug | the real 1947 logbook, ransom-letter numbers, timeline |
| Apollo 13 | NASA photos, quotes, document checklist |
| The Mona Lisa theft | paper maps with pins, police report with a redaction bar |
| ENIAC's six programmers | typed name list, stamps, hall-of-fame number |
Each one renders in about 2 minutes on a normal PC.
Limits
- English voices only out of the box (Kokoro). Any language works if you supply your own
vo.wavper beat. - It is only as good as the photos: dark or busy photos cut badly.
pieces.jpgshows every cut-out before you render. - It makes 9:16 shorts first; 16:9 works but its layouts are less tuned.
Credits
Built by Web Lab. Open-source parts and their licences are listed in CREDITS.md. Code: MIT.
// HOW IT'S BUILT
// FILES
35 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
SKILL.md6.7 KBa6640c2906b9upstream ↗
--- name: papercut description: Make a finished paper-collage documentary short (the Vox-style halftone cut-out look) from one topic, for free, on your own computer. Claude verifies the facts, writes the script, finds public-domain archive photos, and the engine cuts them out, voices the narration, animates every piece in stop-motion, adds paper sound effects and captions, and renders the MP4. Use for "make a collage video about X", "Vox-style explainer", "paper cut-out documentary", "halftone collage short", or any history, tech or true-story short where no footage exists. --- # papercut One topic in, one finished 30-60 s documentary out. No video model, no image model, no credits: | Part | Tool | Licence | |---|---|---| | Script, facts, picture choices | Claude | your plan | | Archive photos | Wikimedia Commons, public domain / CC0 only | free to use | | Cut-outs | rembg (isnet) + our halftone + scissor edge | MIT / Apache-2.0 | | Voice | Kokoro-82M, on the CPU | Apache-2.0 | | Word timing for captions | faster-whisper | MIT | | Paper, foley, music bed | generated in code from a seed | ours, MIT | | Animation + render | HyperFrames (HTML to video) | Apache-2.0 | | Maps | Natural Earth via world-atlas + d3-geo | public domain / ISC | | Fonts | Anton, Archivo Black, Special Elite, Permanent Marker | OFL / Apache-2.0 | Paths (relative to this skill's own folder, the one this SKILL.md is in): - engine: `<skill>/engine/papercut.py` - python: `<skill>/.venv/Scripts/python.exe` on Windows, `<skill>/.venv/bin/python` on macOS / Linux (made by setup) Make one folder per film and run every command from inside it, e.g. `"<python>" "<skill>/engine/papercut.py" make story.json`. ## The workflow (run it in this order, stop where it says) ### 1. Facts first Search the topic. Every date, name and number in the script needs two independent sources. If sources disagree, write around it ("August 1911", not "August 21"). Never invent a quote: use the documented wording, or none. ### 2. Script (beats) - 10-14 beats, 30-45 s. One sentence (or half of one) per beat, one visual idea per beat. - Cold open on a date and a place: "September ninth, 1947. Harvard University." - Calm documentary voice, short declaratives. Spell numbers the way they should be spoken ("Seventy-nine years later"). - End on a short line that lands: "After it, it was." / "We are still debugging." ### 3. Pictures ``` <python> <skill>/engine/papercut.py search "Grace Hopper" sheet.png ``` Open `sheet.png` and pick by looking, not by title. Rules learned the hard way: - People: `"cut": true` (background removed). The face must be well lit and at least ~400 px tall in the source. Dark or busy photos cut badly: crop tight around the person (`"crop": [l, t, r, b]` as fractions) or use `"cut": false`. - Machines, rooms, scenes, space photos: `"cut": false` (a printed photo with tape). - Documents and handwriting: `"cut": false, "dots": false` (clean print, halftone would destroy the writing). - Never show a photo of the wrong object with the right caption (a Mark I is not a Mark II). If no true photo exists, use a `document`, `number` or `timeline` beat instead. ### 4. story.json ```json { "title": "The First Computer Bug", "slug": "first-computer-bug", "format": "9:16", "voice": "af_heart", "music": "bed", "seed": 11, "images": { "hopper": {"commons": "File:Commodore Grace M. Hopper, USN (covered) head and shoulders crop.jpg", "cut": true, "accent": "red"}, "page": {"file": "sources/log.jpg", "cut": false, "dots": false, "accent": "red", "size": 1200} }, "beats": [ {"say": "September ninth, 1947. Harvard University.", "kind": "document", "bg": "mustard", "head": "OPERATIONS LOG", "lines": ["DATE: 9 SEPT 1947", "MACHINE: MARK II"], "label": "09.09.1947"} ] } ``` Image keys: `commons` (exact `File:` title) or `file` (local path), `cut`, `dots`, `crop`, `accent` (red, mustard, teal, null), `size`, `contrast`. Beat kinds (every beat also takes `bg`, `scraps`, `burst`, `transition`): | kind | fields | use for | |---|---|---| | `hero` | `hero`, `label`, `tag`, `stamp`, `stamp_on`, `circle` | a person or object, big | | `photo` | `photo`, `label`, `tag`, `stamp`, `stamp_on` | a scene, a document photo | | `duo` | `left`, `right`, `string`, `label` | two people, a connection | | `number` | `number`, `sub`, `stamp`, `hero` | a date, an amount, a count (ransom letters) | | `document` | `head`, `lines`, `redact`, `mark`, `stamp`, `hero`, `label` | typed text, lists, names | | `timeline` | `dates`, `active`, `hero`, `label` | before / after, a span of years | | `quote` | `quote`, `by`, `hero` | a real, sourced quote | | `map` | `map: {country | highlight[], pin: [lon, lat], place, sea}`, `label`, `stamp` | where it happened | `bg`: mustard, red, teal, navy, newsprint, kraft, cream, pink, olive, ink. Alternate warm and cool; never the same twice in a row. `stamp_on`: a word from `say`; the stamp lands when that word is spoken. Labels: 1-3 words, uppercase. `label_size` shrinks long ones; `|` breaks a line. ### 5. Build, look, fix, render ``` <python> <skill>/engine/papercut.py make story.json # images, voice, animation, sound ``` 1. Open `pieces.jpg`: every cut-out on one sheet. A blob or a missing face means re-crop, raise `contrast`, or switch to `cut: false`. 2. `<python> <skill>/engine/papercut.py snap story.json 2.1,5.4,...` with a time near the end of each beat (beat times are in `project/assets/audio/timing.json`). Check every frame: nothing off the edge, nothing important under the captions, every stamp readable. 3. Fix story.json and run `make` again (cut-outs and voice are cached, only what changed is rebuilt). 4. `<python> <skill>/engine/papercut.py make story.json --render` writes `<slug>.mp4` (1080x1920, 30 fps, -14 LUFS). Credits for every photo are written to `CREDITS.txt`. Put them in the video description. ## What the engine does for you - Halftone cut-outs with a scissor-cut paper border, a misregistered colour offset and a real cast shadow. - Stop-motion on twos: every move is quantised to 12 poses a second, every piece "boils" a pixel or two, the camera pushes in slowly in steps. - Paper wipes between scenes (slide, drop, cut), torn label strips, rubber stamps, marker circles, red string, typewriter documents with redaction bars. - Foley for every entrance (paper slide, tap, stamp thud, tape, typewriter) and a quiet tension bed, mixed under the voice to -14 LUFS. - Captions from the script's own words, timed by whisper, current word highlighted. ## Voices Kokoro voices that work for documentary: `af_heart` (default, best quality), `af_bella`, `bf_emma`, `am_michael`, `bm_george`. English only. For another language, record your own voice as `vo.wav` per beat or swap in a TTS that speaks it.README.md4.0 KBa2fb3d0604ecupstream ↗
# papercut **Vox-style paper-collage documentaries, made by Claude, for free, on your own computer.** One topic in, one finished vertical short out: halftone cut-outs, torn paper, rubber stamps, maps, typewriter documents, stop-motion on twos, paper sound effects, captions and a voice. No video model, no image model, no credits.  <p> <img src="docs/demo-hopper.gif" width="240" alt="Grace Hopper cut-out assembling on red card"> <img src="docs/demo-map.gif" width="240" alt="Paper map of France with a pin on the Louvre"> </p> ## How it works AI video tools invent the picture, and every clip costs credits. papercut builds the picture instead, from real archive photos and code: 1. **Claude** checks the facts, writes a 10-14 beat script and picks the photos. 2. **Wikimedia Commons** supplies the photos. Only public domain and CC0 by default; every photo is credited automatically. 3. **rembg** removes the background, then the engine prints it as halftone dots on paper, cuts a scissor border, adds a misregistered colour offset and a real shadow. 4. **Kokoro** (an open voice model, runs on the CPU) reads the script. **faster-whisper** times every word for the captions. 5. **HyperFrames** animates it as HTML: every piece slides, drops or stamps in at 12 poses a second, boils a pixel like a real animation table, and the camera pushes in step by step. 6. Paper slides, taps, stamp thuds, tape and a typewriter are **synthesised in code**, mixed under the voice to -14 LUFS. Everything that ships in the video is either generated from a seed or free to use. Nothing is rented. ## Install You need Python 3.10+, Node 18+, FFmpeg and git. ```bash git clone https://github.com/basharmokdad/papercut cd papercut ./setup.sh # macOS / Linux # or on Windows: powershell -ExecutionPolicy Bypass -File setup.ps1 ``` Setup makes a Python venv in `.venv`, installs the libraries, downloads the Kokoro voice model (~330 MB) to `~/.papercut/kokoro`, and fetches the map data and animation library into `engine/data`. To use it as a Claude Code skill, link or copy the folder into your skills folder: ```bash ln -s "$(pwd)" ~/.claude/skills/papercut ``` Then ask Claude: *"Make a papercut video about the first computer bug."* ## Use it by hand ```bash mkdir my-film && cd my-film ../.venv/bin/python ../engine/papercut.py search "Grace Hopper" sheet.png # find photos, look at sheet.png # write story.json (see examples/ and SKILL.md) ../.venv/bin/python ../engine/papercut.py make story.json # build ../.venv/bin/python ../engine/papercut.py snap story.json 3.2,7.5 # check frames ../.venv/bin/python ../engine/papercut.py make story.json --render # render the MP4 ``` Each film is one `story.json`: the images and a list of beats. A beat is one line of narration plus one scene: `hero`, `photo`, `duo`, `number`, `document`, `timeline`, `quote` or `map`. The full reference and the picture rules are in [SKILL.md](SKILL.md). ## Examples | Film | Scenes it shows off | |---|---| | [The first computer bug](examples/01-first-bug/story.json) | the real 1947 logbook, ransom-letter numbers, timeline | | [Apollo 13](examples/02-apollo-13/story.json) | NASA photos, quotes, document checklist | | [The Mona Lisa theft](examples/03-mona-lisa/story.json) | paper maps with pins, police report with a redaction bar | | [ENIAC's six programmers](examples/04-eniac/story.json) | typed name list, stamps, hall-of-fame number | Each one renders in about 2 minutes on a normal PC. ## Limits - English voices only out of the box (Kokoro). Any language works if you supply your own `vo.wav` per beat. - It is only as good as the photos: dark or busy photos cut badly. `pieces.jpg` shows every cut-out before you render. - It makes 9:16 shorts first; 16:9 works but its layouts are less tuned. ## Credits Built by [Web Lab](https://instagram.com/weblabai). Open-source parts and their licences are listed in [CREDITS.md](CREDITS.md). Code: MIT..gitattributes17 Bdfdb8b771ce0upstream ↗
*.sh text eol=lf.gitignore171 B1ff78fd8c917upstream ↗
.venv/ _test/ __pycache__/ engine/data/*.js engine/data/*.json **/project/ **/sources/ **/snaps*/ **/*.mp4 **/pieces.jpg **/sheet.png **/sheet.png.json engine/CREDITS.txtCREDITS.md1.3 KB0ddc959608adupstream ↗
# Credits and licences papercut's own code is MIT (see LICENSE). It stands on these: | Part | Project | Licence | |---|---|---| | Animation and rendering | [HyperFrames](https://github.com/heygen-com/hyperframes) | Apache-2.0 | | Animation timeline | [GSAP](https://gsap.com) (downloaded by setup) | GSAP Standard License, free | | Voice | [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [kokoro-onnx](https://github.com/thewh1teagle/kokoro-onnx) | Apache-2.0 / MIT | | Word timings | [faster-whisper](https://github.com/SYSTRAN/faster-whisper) | MIT | | Background removal | [rembg](https://github.com/danielgatis/rembg) with the IS-Net model | MIT / Apache-2.0 | | Maps | [Natural Earth](https://www.naturalearthdata.com) via [world-atlas](https://github.com/topojson/world-atlas), [d3-geo](https://github.com/d3/d3-geo), [topojson-client](https://github.com/topojson/topojson-client) | public domain / ISC | | Photos | [Wikimedia Commons](https://commons.wikimedia.org), public domain and CC0 only by default | per photo, written to each film's CREDITS.txt | | Fonts | Anton, Archivo Black (SIL OFL 1.1); Special Elite, Permanent Marker (Apache-2.0) | licence files in `engine/fonts` | Paper textures, scraps, tape, grain, every sound effect and the music bed are generated by papercut's code from a seed.docs/demo-hopper.gif3.4 MB220e202c4840upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/demo-map.gif3.6 MB9459b38ec710upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/frames.jpg345 KBae333e30e32bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
engine/build.py43 KB7376eb336d1bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
engine/cutout.py6.2 KB8b604b170e3eupstream ↗
"""Turn any photo into a hand-cut halftone paper piece. photo -> (optional) background removal -> contrast -> halftone dots -> scissor-cut paper border -> offset accent silhouette (misregistered print) -> baked soft shadow -> RGBA PNG Background removal is rembg (MIT) with the u2net / isnet models (Apache-2.0), on the CPU. """ import math import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFilter, ImageOps from paper import INK, PAPER, CARDS, card, with_shadow _SESSION = None def _mask_rembg(img): global _SESSION from rembg import new_session, remove if _SESSION is None: _SESSION = new_session("isnet-general-use") m = remove(img, session=_SESSION, only_mask=True) m = np.asarray(m.convert("L")) m = (m > 110).astype(np.uint8) # keep the big parts only, close small gaps, fill pinholes n, lab, stats, _ = cv2.connectedComponentsWithStats(m, 8) if n > 1: big = stats[1:, cv2.CC_STAT_AREA].max() keep = [i for i in range(1, n) if stats[i, cv2.CC_STAT_AREA] > 0.08 * big] m = np.isin(lab, keep).astype(np.uint8) k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k) cnts, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) filled = np.zeros_like(m) cv2.drawContours(filled, cnts, -1, 1, -1) return filled * 255 def halftone(gray, mask, cell=8, angle=45, sup=3): """Ink dots on paper. gray: float32 0..1 (1 = white). Returns an L image, 0 = ink.""" h, w = gray.shape W, H = w * sup, h * sup out = Image.new("L", (W, H), 255) d = ImageDraw.Draw(out) a = math.radians(angle) ca, sa = math.cos(a), math.sin(a) c = cell * sup diag = int(math.hypot(W, H)) + c cx, cy = W / 2, H / 2 for v in range(-diag // 2, diag // 2, c): for u in range(-diag // 2, diag // 2, c): x = cx + u * ca - v * sa y = cy + u * sa + v * ca if 0 <= x < W and 0 <= y < H: px, py = int(x / sup), int(y / sup) if mask[py, px] < 128: continue dark = 1 - gray[py, px] r = c * 0.71 * math.sqrt(max(0.0, dark)) if r > 0.5: d.ellipse([x - r, y - r, x + r, y + r], fill=0) return out.resize((w, h), Image.LANCZOS) def _scissor_poly(mask, grow, seed, eps=5.0): """Grow the silhouette into a paper border and cut it with straight scissor strokes.""" rng = np.random.default_rng(seed) k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (grow * 2 + 1, grow * 2 + 1)) big = cv2.dilate((mask > 127).astype(np.uint8), k) cnts, _ = cv2.findContours(big, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) out = np.zeros_like(big) for c in cnts: if cv2.contourArea(c) < 400: continue p = cv2.approxPolyDP(c, eps, True).reshape(-1, 2).astype(np.float32) p += rng.normal(0, 1.2, p.shape) # a hand never cuts perfectly cv2.fillPoly(out, [p.astype(np.int32)], 1) return out * 255 def cut_piece(src, size=900, cut=True, accent="red", border=14, seed=1, contrast=1.1, cell=None, offset=(16, 12), paper=PAPER, dots=True): """Return an RGBA PIL image of the finished paper piece (shadow included).""" img = Image.open(src).convert("RGB") img = ImageOps.exif_transpose(img) s = size / max(img.size) img = img.resize((max(1, int(img.width * s)), max(1, int(img.height * s))), Image.LANCZOS) w, h = img.size if cut: m = _mask_rembg(img) else: m = np.full((h, w), 255, np.uint8) # tone: autocontrast inside the subject, a little local contrast, then a gentle S-curve g = ImageOps.grayscale(img) g = ImageOps.autocontrast(g, cutoff=2, mask=Image.fromarray(m)) g = g.filter(ImageFilter.UnsharpMask(radius=6, percent=60, threshold=2)) ga = np.asarray(g, np.float32) / 255 ga = np.clip(0.5 + (ga - 0.5) * contrast, 0, 1) ga = ga ** 0.72 # lift the mids so faces keep detail ga = 0.10 + ga * 0.90 # deepest shadow = 90% ink, never a solid black blob cell = cell or max(6, int(round(size / 115))) if dots: ht = np.asarray(halftone(ga, m, cell=cell), np.float32) / 255 # 1 paper, 0 ink else: # clean print for documents and handwriting: keep the detail, add a fine print grain rng = np.random.default_rng(seed) ht = np.clip(ga + rng.normal(0, 0.035, ga.shape), 0, 1).astype(np.float32) # canvas with room for border + accent offset pad = border + max(abs(offset[0]), abs(offset[1])) + 8 W, H = w + pad * 2, h + pad * 2 M = np.zeros((H, W), np.uint8) M[pad:pad + h, pad:pad + w] = m if cut: bmask = _scissor_poly(M, border, seed) else: rng = np.random.default_rng(seed) j = lambda: rng.normal(0, 2.5) q = [(pad - border + j(), pad - border + j()), (pad + w + border + j(), pad - border + j()), (pad + w + border + j(), pad + h + border + j()), (pad - border + j(), pad + h + border + j())] im = Image.new("L", (W, H), 0) ImageDraw.Draw(im).polygon(q, fill=255) bmask = np.asarray(im) paper_tex = np.asarray(card(paper, W, H, seed + 3), np.float32) rgb = paper_tex.copy() ink = np.array(INK, np.float32) inner = np.zeros((H, W), np.float32) inner[pad:pad + h, pad:pad + w] = (m > 127) * (1 - ht) rgb = rgb * (1 - inner[..., None]) + ink[None, None, :] * inner[..., None] layers = [] if accent: acol = np.array(CARDS.get(accent, accent), np.float32) am = np.roll(np.roll(bmask, offset[1], 0), offset[0], 1) acc = Image.new("RGBA", (W, H), tuple(int(v) for v in acol) + (0,)) acc.putalpha(Image.fromarray(am)) layers.append(acc) piece = Image.fromarray(np.clip(rgb, 0, 255).astype(np.uint8)).convert("RGBA") piece.putalpha(Image.fromarray(bmask)) layers.append(piece) comp = Image.new("RGBA", (W, H), (0, 0, 0, 0)) for L in layers: comp = Image.alpha_composite(comp, L) bbox = comp.getbbox() comp = comp.crop(bbox) return with_shadow(comp, pad=26) if __name__ == "__main__": import sys cut_piece(sys.argv[1], cut="--rect" not in sys.argv).save(sys.argv[2])engine/fetch.py5.2 KBc231fee35e27upstream ↗
"""Find real archive photos you are allowed to use: Wikimedia Commons, public domain and CC0 only by default. search "Harvard Mark II" -> numbered candidates + a contact sheet PNG to look at get "File:Harvard_Mark_II.jpg" out -> downloads it and appends the credit line to CREDITS.txt CC BY / CC BY-SA images are allowed with --allow-by; their author + licence go into the credits and must be shown. """ import json import os import re import sys import time import requests from PIL import Image, ImageDraw, ImageFont API = "https://commons.wikimedia.org/w/api.php" UA = {"User-Agent": "PapercutVideoEngine/1.0 (free paper-collage video engine)"} FREE = re.compile(r"public domain|^pd|cc0|no restrictions", re.I) BY = re.compile(r"^cc[ -]by", re.I) def _get(params, tries=4): for i in range(tries): r = requests.get(API, params=params, headers=UA, timeout=30) if r.status_code == 429: time.sleep(2 + i * 3) continue r.raise_for_status() return r.json() r.raise_for_status() def _clean(html): return re.sub(r"<[^>]+>", "", html or "").strip() def search(query, limit=24, allow_by=False): data = _get({ "action": "query", "format": "json", "generator": "search", "gsrnamespace": 6, "gsrsearch": f"{query} filetype:bitmap", "gsrlimit": limit, "prop": "imageinfo", "iiprop": "url|size|mime|extmetadata", "iiurlwidth": 1600, }) out = [] for p in sorted(data.get("query", {}).get("pages", {}).values(), key=lambda p: p.get("index", 0)): ii = (p.get("imageinfo") or [{}])[0] md = ii.get("extmetadata", {}) lic = _clean(md.get("LicenseShortName", {}).get("value")) ok = bool(FREE.search(lic)) or (allow_by and bool(BY.search(lic))) if not ok or ii.get("width", 0) < 500: continue out.append({ "title": p["title"], "license": lic, "w": ii.get("width"), "h": ii.get("height"), "thumb": ii.get("thumburl") or ii.get("url"), "page": ii.get("descriptionurl"), "artist": _clean(md.get("Artist", {}).get("value"))[:80], }) return out def contact_sheet(cands, path, cols=4, tw=300): font = ImageFont.load_default() rows = (len(cands) + cols - 1) // cols sheet = Image.new("RGB", (cols * tw, rows * (tw + 40)), (30, 30, 30)) for i, c in enumerate(cands): try: r = requests.get(c["thumb"].replace("/1600px-", "/400px-"), headers=UA, timeout=30) from io import BytesIO im = Image.open(BytesIO(r.content)).convert("RGB") im.thumbnail((tw - 8, tw - 8)) except Exception: continue x, y = (i % cols) * tw, (i // cols) * (tw + 40) sheet.paste(im, (x + 4, y + 4)) d = ImageDraw.Draw(sheet) d.text((x + 6, y + tw + 2), f"{i + 1}. {c['title'][5:40]}", fill=(255, 255, 255), font=font) d.text((x + 6, y + tw + 18), f"{c['license'][:30]} {c['w']}x{c['h']}", fill=(180, 180, 180), font=font) sheet.save(path) def get(title, out, credits="CREDITS.txt", allow_by=False): data = _get({"action": "query", "format": "json", "titles": title, "prop": "imageinfo", "iiprop": "url|extmetadata", "iiurlwidth": 1280}) # 1280 is a standard Wikimedia thumb step page = next(iter(data["query"]["pages"].values())) ii = page["imageinfo"][0] md = ii.get("extmetadata", {}) lic = _clean(md.get("LicenseShortName", {}).get("value")) if not (FREE.search(lic) or (allow_by and BY.search(lic))): raise SystemExit(f"refused: {title} is '{lic}', not public domain/CC0 (use --allow-by for CC BY)") url = (ii.get("thumburl") or ii["url"]).split("?")[0] # tracking params get rate-limited os.makedirs(os.path.dirname(out) or ".", exist_ok=True) # curl, not requests: Wikimedia's edge rate-limits python's HTTP fingerprint on upload.wikimedia.org import subprocess for i in range(5): code = subprocess.run(["curl", "-s", "-L", "-o", out, "-w", "%{http_code}", "-A", UA["User-Agent"], url], capture_output=True, text=True).stdout.strip() if code == "200": break time.sleep(5 * (i + 1)) if code != "200": raise SystemExit(f"download failed ({code}): {url}") time.sleep(1.0) artist = _clean(md.get("Artist", {}).get("value"))[:120] line = f"{os.path.basename(out)}: {title} | {artist or 'unknown'} | {lic} | {ii.get('descriptionurl')}\n" existing = open(credits, encoding="utf-8").read() if os.path.exists(credits) else "" if line not in existing: open(credits, "a", encoding="utf-8").write(line) return out if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8") cmd = sys.argv[1] allow = "--allow-by" in sys.argv args = [a for a in sys.argv[2:] if not a.startswith("--")] if cmd == "search": c = search(args[0], allow_by=allow) for i, x in enumerate(c, 1): print(f"{i:2d}. {x['title']} [{x['license']}] {x['w']}x{x['h']}") if len(args) > 1: contact_sheet(c, args[1]) json.dump(c, open(args[1] + ".json", "w", encoding="utf-8"), indent=1) print("sheet:", args[1]) elif cmd == "get": print(get(args[0], args[1], allow_by=allow))engine/fonts/Anton-Regular.ttf167 KB4d65707db9d8upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
engine/fonts/ArchivoBlack-Regular.ttf89 KB82d07de3b84eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
engine/fonts/LICENSE-PermanentMarker.txt11 KBd64569567334upstream ↗
Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.engine/fonts/LICENSE-SpecialElite.txt11 KBd64569567334upstream ↗
Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.engine/fonts/OFL-Anton.txt4.3 KBefd0dd80c2deupstream ↗
Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL ----------------------------------------------------------- SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ----------------------------------------------------------- PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.engine/fonts/OFL-ArchivoBlack.txt4.3 KB147454335649upstream ↗
Copyright 2017 The Archivo Black Project Authors (https://github.com/Omnibus-Type/ArchivoBlack) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL ----------------------------------------------------------- SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ----------------------------------------------------------- PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.engine/fonts/PermanentMarker-Regular.ttf73 KB3218fc5b3d39upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
engine/fonts/SpecialElite-Regular.ttf162 KB6654876f95f1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
engine/paper.py5.9 KBc852c13f056cupstream ↗
"""Procedural paper: every texture is generated from a seed, so the engine ships with no image files to license. Backgrounds are flat coloured card with fibres, stains and print grain. Scraps are blank newsprint with torn edges and faint halftone, never readable text (invented headlines are what make AI collage look fake). """ import numpy as np from PIL import Image, ImageDraw, ImageFilter # Card colours. One hot signal colour, earthy neutrals, ink. Keys are what story.json uses for "bg". CARDS = { "red": (196, 48, 38), "mustard": (214, 165, 52), "teal": (38, 104, 104), "navy": (30, 42, 66), "newsprint": (226, 216, 194), "kraft": (176, 136, 92), "ink": (28, 26, 24), "cream": (238, 230, 212), "pink": (222, 150, 140), "olive": (120, 120, 72), } INK = (26, 23, 20) PAPER = (240, 233, 219) def _noise(h, w, rng, scales=(1, 4, 16, 64), weights=(0.35, 0.3, 0.2, 0.15)): """Multi-octave value noise in [0,1], built by upsampling random grids (fast, no extra deps).""" out = np.zeros((h, w), np.float32) for s, wt in zip(scales, weights): gh, gw = max(2, h // s), max(2, w // s) g = rng.random((gh, gw)).astype(np.float32) im = Image.fromarray((g * 255).astype(np.uint8)).resize((w, h), Image.BICUBIC) out += wt * (np.asarray(im, np.float32) / 255) return out / sum(weights) def card(color, w, h, seed=0): """A full-frame sheet of coloured card: fibres, soft stains, edge darkening, print grain.""" rng = np.random.default_rng(seed) base = np.array(CARDS.get(color, color), np.float32) n = _noise(h, w, rng) fib = _noise(h, w, rng, scales=(1, 2), weights=(0.6, 0.4)) stain = _noise(h, w, rng, scales=(128, 256), weights=(0.5, 0.5)) yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) vig = ((xx / w - 0.5) ** 2 + (yy / h - 0.5) ** 2) ** 0.5 shade = 1 + 0.10 * (n - 0.5) + 0.05 * (fib - 0.5) - 0.10 * np.clip(stain - 0.55, 0, 1) - 0.22 * vig ** 2 img = np.clip(base[None, None, :] * shade[..., None], 0, 255) # sparse fibres: short light and dark hairs im = Image.fromarray(img.astype(np.uint8)) d = ImageDraw.Draw(im, "RGBA") for _ in range(int(w * h / 9000)): x, y = rng.random() * w, rng.random() * h a, ln = rng.random() * np.pi, 4 + rng.random() * 14 c = (255, 255, 255, 28) if rng.random() < 0.5 else (0, 0, 0, 22) d.line([(x, y), (x + np.cos(a) * ln, y + np.sin(a) * ln)], fill=c, width=1) return im def torn_mask(w, h, seed=0, tear=10, edges="tblr"): """An RGBA-ready L mask for a rectangle whose chosen edges are torn (jagged, fibrous).""" rng = np.random.default_rng(seed) pts = [] def edge(p0, p1, torn): n = max(2, int(np.hypot(p1[0] - p0[0], p1[1] - p0[1]) / 4)) walk = 0.0 for i in range(n): t = i / n x, y = p0[0] + (p1[0] - p0[0]) * t, p0[1] + (p1[1] - p0[1]) * t if torn: # slow wander (the tear line) + fine fibre fuzz, pushed inwards only walk = 0.85 * walk + rng.normal(0, 0.55) k = np.clip(0.5 + walk * 0.25, 0, 1) * tear + rng.random() * tear * 0.25 dx, dy = p1[1] - p0[1], -(p1[0] - p0[0]) L = np.hypot(dx, dy) or 1 x, y = x + dx / L * k, y + dy / L * k pts.append((x, y)) m = tear edge((m, m), (w - m, m), "t" in edges) edge((w - m, m), (w - m, h - m), "r" in edges) edge((w - m, h - m), (m, h - m), "b" in edges) edge((m, h - m), (m, m), "l" in edges) mask = Image.new("L", (w, h), 0) ImageDraw.Draw(mask).polygon(pts, fill=255) return mask def halftone_field(w, h, seed=0, cell=9, strength=0.22): """Faint, unreadable halftone grain: the 'printed newsprint' look without any letters.""" rng = np.random.default_rng(seed) tone = _noise(h, w, rng, scales=(64, 160), weights=(0.6, 0.4)) sup = 2 W, H = w * sup, h * sup im = Image.new("L", (W, H), 0) d = ImageDraw.Draw(im) c = cell * sup for y in range(0, H, c): for x in range(0, W, c): ox = (c // 2) if (y // c) % 2 else 0 v = tone[min(h - 1, y // sup), min(w - 1, (x + ox) // sup)] r = c * 0.5 * (v * strength * 2) if r > 0.6: d.ellipse([x + ox - r, y - r, x + ox + r, y + r], fill=255) return np.asarray(im.resize((w, h), Image.LANCZOS), np.float32) / 255 def scrap(w, h, seed=0, color="newsprint", dots=True): """A torn piece of blank newsprint/card with soft shadow baked in (RGBA, padded for the shadow).""" pad = 24 body = card(color, w, h, seed).convert("RGBA") if dots: hf = halftone_field(w, h, seed + 7) arr = np.asarray(body, np.float32) arr[..., :3] *= (1 - 0.35 * hf[..., None]) body = Image.fromarray(arr.astype(np.uint8), "RGBA") mask = torn_mask(w, h, seed, tear=9) body.putalpha(mask) return with_shadow(body, pad) def with_shadow(rgba, pad=24, off=(6, 10), blur=9, alpha=0.45): """Bake a soft cast shadow under an RGBA cut-out: paper lifted a few millimetres off the table.""" w, h = rgba.size out = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0)) a = rgba.split()[3] sh = Image.new("L", out.size, 0) sh.paste(a, (pad + off[0], pad + off[1])) sh = sh.filter(ImageFilter.GaussianBlur(blur)).point(lambda v: int(v * alpha)) shadow = Image.new("RGBA", out.size, (20, 14, 8, 0)) shadow.putalpha(sh) out = Image.alpha_composite(out, shadow) layer = Image.new("RGBA", out.size, (0, 0, 0, 0)) layer.paste(rgba, (pad, pad)) return Image.alpha_composite(out, layer) def tape(w=220, h=64, seed=0): """Masking tape: translucent cream with torn short ends.""" rng = np.random.default_rng(seed) t = card((222, 208, 170), w, h, seed).convert("RGBA") m = torn_mask(w, h, seed, tear=6, edges="lr") m = m.point(lambda v: int(v * (0.78 + 0.0 * rng.random()))) t.putalpha(m) return tengine/papercut.py7.6 KB4a9e5b3ccc3cupstream ↗
"""papercut: one story.json in, one stop-motion paper-collage documentary out. Free, local, no credits. python papercut.py search "Apollo 13" sheet.png find public-domain photos (prints + contact sheet) python papercut.py make story.json images -> cutouts -> voice -> timings -> animation -> sound python papercut.py make story.json --render ...and render the MP4 python papercut.py snap story.json 3.2,7.5 stills at those seconds, to check a frame before rendering """ import hashlib import json import os import shutil import subprocess import sys import numpy as np import soundfile as sf from scipy.signal import resample_poly HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import build # noqa: E402 import fetch # noqa: E402 import sfx # noqa: E402 import voice # noqa: E402 from cutout import cut_piece # noqa: E402 NPX = shutil.which("npx") or "npx" def _h(o): return hashlib.sha1(json.dumps(o, sort_keys=True).encode()).hexdigest()[:10] def load(path): story = json.load(open(path, encoding="utf-8")) root = os.path.dirname(os.path.abspath(path)) proj = os.path.join(root, story.get("project", "project")) return story, root, proj def images(story, root, proj): src_dir = os.path.join(root, "sources") out_dir = os.path.join(proj, "assets", "img") os.makedirs(src_dir, exist_ok=True) os.makedirs(out_dir, exist_ok=True) credits = os.path.join(root, "CREDITS.txt") done = {} for key, spec in story.get("images", {}).items(): if isinstance(spec, str): spec = {"commons": spec} if "file" in spec: raw = os.path.join(root, spec["file"]) else: ext = os.path.splitext(spec["commons"])[1].lower() ext = ".jpg" if ext in (".tif", ".tiff", ".webp", ".svg") else ext raw = os.path.join(src_dir, key + ext) if not os.path.exists(raw): fetch.get(spec["commons"], raw, credits=credits, allow_by=spec.get("allow_by", False)) params = {k: spec.get(k) for k in ("cut", "accent", "size", "contrast", "crop", "dots")} tag = _h([params, os.path.getsize(raw)]) out = os.path.join(out_dir, f"{key}-{tag}.png") if not os.path.exists(out): src = raw if spec.get("crop"): # [l, t, r, b] as fractions, to frame a face or an object before cutting from PIL import Image im = Image.open(raw).convert("RGB") l, t, r, b = spec["crop"] im = im.crop((int(l * im.width), int(t * im.height), int(r * im.width), int(b * im.height))) src = os.path.join(src_dir, key + "-crop.png") im.save(src) piece = cut_piece(src, size=spec.get("size", 1000), cut=spec.get("cut", True), accent=spec.get("accent", "red"), contrast=spec.get("contrast", 1.1), seed=len(key), dots=spec.get("dots", True)) piece.save(out) print(" piece", key) done[key] = os.path.relpath(out, proj).replace("\\", "/") # one sheet of every cut-out, so a bad cut is caught before it reaches a scene from PIL import Image tiles = [] for key, rel in done.items(): im = Image.open(os.path.join(proj, rel)) im.thumbnail((360, 420)) t = Image.new("RGBA", (380, 440), (90, 110, 110, 255)) t.alpha_composite(im, ((380 - im.width) // 2, (440 - im.height) // 2)) tiles.append(t) if tiles: cols = 4 sheet = Image.new("RGB", (380 * cols, 440 * ((len(tiles) + cols - 1) // cols)), (60, 60, 60)) for i, t in enumerate(tiles): sheet.paste(t.convert("RGB"), ((i % cols) * 380, (i // cols) * 440)) sheet.save(os.path.join(root, "pieces.jpg"), quality=85) return done def narrate(story, proj): ad = os.path.join(proj, "assets", "audio") os.makedirs(ad, exist_ok=True) key = _h([[b["say"] for b in story["beats"]], story.get("voice", "af_heart"), story.get("speed", 1.0)]) cache = os.path.join(ad, "timing.json") if os.path.exists(cache): c = json.load(open(cache)) if c.get("key") == key: return c["timing"], c["words"], c["total"] vo, timing, total = voice.synth(story["beats"], ad, story.get("voice", "af_heart"), story.get("speed", 1.0), lang=story.get("lang", "en-us")) words = voice.words(vo, story["beats"], timing) json.dump({"key": key, "timing": timing, "words": words, "total": total}, open(cache, "w"), indent=1) print(f" voice {total:.1f}s, {len(timing)} beats") return timing, words, total def mix(story, proj, events, total): ad = os.path.join(proj, "assets", "audio") vo, sr = sf.read(os.path.join(ad, "vo.wav"), dtype="float32") vo = resample_poly(vo, 2, 1) if sr == 24000 else vo n = int((total + 0.5) * sfx.SR) out = np.zeros(n, np.float32) out[: min(n, len(vo))] += vo[:n] fx = sfx.make_events(events, total)[:n] out[: len(fx)] += fx * story.get("sfx_gain", 0.32) if story.get("music", "bed") == "bed": bd = sfx.bed(total, bpm=story.get("bpm", 72))[:n] out[: len(bd)] += bd * story.get("music_gain", 0.22) elif story.get("music") not in (None, "none"): m, msr = sf.read(os.path.join(os.path.dirname(proj), story["music"]), dtype="float32") if m.ndim > 1: m = m.mean(1) if msr != sfx.SR: m = resample_poly(m, sfx.SR, msr) m = m[:n] out[: len(m)] += m * story.get("music_gain", 0.12) raw = os.path.join(ad, "mix_raw.wav") sf.write(raw, out / (np.max(np.abs(out)) or 1) * 0.7, sfx.SR) subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", raw, "-af", "loudnorm=I=-14:TP=-1.5:LRA=9,alimiter=limit=0.84:level=0", "-ar", "48000", os.path.join(ad, "mix.wav")], check=True) def make(path, render=False): story, root, proj = load(path) os.makedirs(proj, exist_ok=True) print("images") imgs = images(story, root, proj) print("voice") timing, words, total = narrate(story, proj) print("animation") events = build.write_project(story, timing, words, total, proj, imgs) print("sound") mix(story, proj, events, total) r = subprocess.run([NPX, "hyperframes", "check", proj], capture_output=True, text=True, encoding="utf-8", errors="replace") errs = [ln.strip() for ln in (r.stdout + r.stderr).splitlines() if ln.strip().startswith("✗")] print(f"check: {len(errs)} errors") for e in errs[:10]: print(" ", e[:220]) if render: out = os.path.join(root, story.get("slug", "video") + ".mp4") subprocess.run([NPX, "hyperframes", "render", proj, "-o", out, "-q", "delivery", "--quiet"], check=True) print("rendered", out) return proj if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8") cmd = sys.argv[1] if cmd == "make": make(sys.argv[2], render="--render" in sys.argv) elif cmd == "search": c = fetch.search(sys.argv[2], allow_by="--allow-by" in sys.argv) for i, x in enumerate(c, 1): print(f"{i:2d}. {x['title']} [{x['license']}] {x['w']}x{x['h']}") if len(sys.argv) > 3 and not sys.argv[3].startswith("--"): fetch.contact_sheet(c, sys.argv[3]) print("sheet:", sys.argv[3]) elif cmd == "snap": story, root, proj = load(sys.argv[2]) out = os.path.join(root, "snaps") subprocess.run([NPX, "hyperframes", "snapshot", proj, "--at", sys.argv[3], "-o", out, "--no-end"], check=True) print(out)engine/sfx.py4.6 KBc57043b68c0cupstream ↗
"""Paper foley and a documentary bed, synthesised from noise and sine waves: no sample packs, nothing to license. events: [{"t": 1.23, "k": "slide"|"drop"|"stamp"|"tape"|"wipe"|"type"|"draw"|"pin"}] """ import numpy as np import soundfile as sf from scipy.signal import butter, sosfilt SR = 48000 def _bp(x, lo, hi, order=2): sos = butter(order, [lo, hi], btype="band", fs=SR, output="sos") return sosfilt(sos, x) def _lp(x, f, order=2): return sosfilt(butter(order, f, btype="low", fs=SR, output="sos"), x) def _env(n, a, d, curve=3.0): t = np.arange(n) / SR e = np.minimum(1, t / max(a, 1e-4)) * np.exp(-np.maximum(0, t - a) / max(d, 1e-4) * curve / 3) return e def paper_slide(rng, dur=0.28): n = int(dur * SR) x = rng.standard_normal(n) # paper friction: band noise with a grainy amplitude flutter flutter = 0.6 + 0.4 * np.abs(_lp(rng.standard_normal(n), 60)) x = _bp(x, 1800, 7000) * flutter e = np.sin(np.linspace(0, np.pi, n)) ** 1.5 return x * e * 0.35 def tap(rng, dur=0.12, body=180): n = int(dur * SR) t = np.arange(n) / SR click = _bp(rng.standard_normal(n), 2000, 8000) * _env(n, 0.001, 0.012) thump = np.sin(2 * np.pi * body * t) * _env(n, 0.002, 0.05) return click * 0.5 + thump * 0.45 def stamp(rng): n = int(0.3 * SR) t = np.arange(n) / SR thud = np.sin(2 * np.pi * (95 - 30 * t) * t) * _env(n, 0.002, 0.12) slap = _bp(rng.standard_normal(n), 400, 3000) * _env(n, 0.001, 0.03) return thud * 0.9 + slap * 0.6 def tape_rip(rng, dur=0.35): n = int(dur * SR) x = _bp(rng.standard_normal(n), 2500, 9000) crackle = (rng.random(n) < 0.02) * rng.standard_normal(n) * 3 e = _env(n, 0.03, dur * 0.6) return (x + _bp(crackle, 1500, 9000)) * e * 0.3 def wipe(rng, dur=0.42): n = int(dur * SR) x = rng.standard_normal(n) lo = _bp(x, 300, 2500) hi = _bp(x, 2500, 9000) e = np.sin(np.linspace(0, np.pi, n)) ** 0.8 return (lo * 0.5 + hi * 0.35) * e * 0.5 def typewriter(rng, n_keys=12, rate=14): out = np.zeros(int((n_keys / rate + 0.2) * SR)) for i in range(n_keys): k = tap(rng, 0.06, body=900 + rng.random() * 300) * (0.5 + 0.3 * rng.random()) s = int((i / rate + rng.normal(0, 0.008)) * SR) s = max(0, s) out[s:s + len(k)] += k[: len(out) - s] return out * 0.7 def marker(rng, dur=0.5): n = int(dur * SR) x = _bp(rng.standard_normal(n), 3000, 10000) e = (0.5 + 0.5 * np.sin(np.linspace(0, 6 * np.pi, n))) * np.sin(np.linspace(0, np.pi, n)) return x * e * 0.18 def make_events(events, total, seed=7): rng = np.random.default_rng(seed) mix = np.zeros(int((total + 1) * SR)) for ev in events: k = ev["k"] x = {"slide": lambda: paper_slide(rng), "drop": lambda: tap(rng), "pin": lambda: tap(rng, 0.08, 1200), "stamp": lambda: stamp(rng), "tape": lambda: tape_rip(rng), "wipe": lambda: wipe(rng), "type": lambda: typewriter(rng, ev.get("n", 12)), "draw": lambda: marker(rng)}.get(k, lambda: tap(rng))() x = x * ev.get("g", 1.0) s = int(max(0, ev["t"]) * SR) e = min(len(mix), s + len(x)) mix[s:e] += x[: e - s] return mix def bed(total, seed=3, bpm=72): """Low documentary tension: a slow minor pad, a soft heartbeat pulse and a clock tick.""" rng = np.random.default_rng(seed) n = int((total + 1) * SR) t = np.arange(n) / SR # chord roots (A minor -> F -> C -> E), one chord per 4 beats roots = [110.0, 87.31, 130.81, 82.41] bar = 4 * 60 / bpm out = np.zeros(n) for i, r in enumerate(roots * int(total / (bar * 4) + 2)): a = int(i * bar * SR) if a >= n: break z = min(n, a + int(bar * SR * 1.15)) tt = t[a:z] - t[a] env = np.minimum(1, tt / 0.8) * np.minimum(1, (tt[-1] - tt) / 0.6 + 0.0) tones = sum(np.sin(2 * np.pi * f * tt + p) for f, p in [(r, 0), (r * 1.003, 1), (r * 1.5, 2), (r * 2.38, 0.5), (r * 0.5, 0.2)]) out[a:z] += tones * env * 0.08 out = _lp(out, 1400) beat = 60 / bpm for k in range(int(total / beat) + 1): s = int(k * beat * SR) hb = tap(rng, 0.25, body=55) * 0.5 e = min(n, s + len(hb)) out[s:e] += hb[: e - s] * 0.6 tk = _bp(rng.standard_normal(int(0.02 * SR)), 3000, 8000) * _env(int(0.02 * SR), 0.0005, 0.004) s2 = int((k * beat + beat / 2) * SR) e2 = min(n, s2 + len(tk)) out[s2:e2] += tk[: e2 - s2] * 0.12 fade = np.minimum(1, t / 1.5) * np.minimum(1, (t[-1] - t) / 1.5) return out * fade def write(path, x): peak = np.max(np.abs(x)) or 1 sf.write(path, (x / peak * 0.8).astype(np.float32), SR)engine/voice.py3.7 KB836d7e21e9c8upstream ↗
"""Narration with Kokoro (Apache-2.0, runs on the CPU, free for commercial use) + word timings for captions. One wav per beat, so every beat's length is exact. Captions use the script's own words, timed by faster-whisper (MIT): the script text is mapped onto whisper's word clock by character position, so spelling always matches. """ import json import os import re import numpy as np import soundfile as sf def _models(): for d in (os.environ.get("PAPERCUT_KOKORO"), os.path.join(os.path.expanduser("~"), ".papercut", "kokoro"), r"C:\kokoro\models"): if d and os.path.exists(os.path.join(d, "kokoro-v1.0.onnx")): return d raise SystemExit("Kokoro models not found: run setup (it downloads them to ~/.papercut/kokoro)") GAP = 0.18 # silence between beats (s) def synth(beats, out_dir, voice="af_heart", speed=1.0, lang="en-us"): from kokoro_onnx import Kokoro km = _models() k = Kokoro(os.path.join(km, "kokoro-v1.0.onnx"), os.path.join(km, "voices-v1.0.bin")) os.makedirs(out_dir, exist_ok=True) clips, sr = [], 24000 for i, b in enumerate(beats): x, sr = k.create(b["say"], voice=voice, speed=speed, lang=lang) x = np.asarray(x, np.float32) # trim Kokoro's own leading/trailing silence so beats sit tight, then add a fixed gap nz = np.where(np.abs(x) > 0.004)[0] if len(nz): x = x[max(0, nz[0] - int(0.02 * sr)): nz[-1] + int(0.06 * sr)] clips.append(x) t, timing, parts = 0.25, [], [np.zeros(int(0.25 * sr), np.float32)] for x in clips: d = len(x) / sr timing.append({"start": round(t, 3), "dur": round(d, 3)}) parts += [x, np.zeros(int(GAP * sr), np.float32)] t += d + GAP parts.append(np.zeros(int(0.6 * sr), np.float32)) full = np.concatenate(parts) path = os.path.join(out_dir, "vo.wav") sf.write(path, full, sr, subtype="PCM_16") return path, timing, len(full) / sr def words(vo_path, beats, timing, model="small"): """Script tokens with start/end times, grouped per beat.""" from faster_whisper import WhisperModel m = WhisperModel(model, device="cpu", compute_type="int8") from scipy.signal import resample_poly x, sr = sf.read(vo_path, dtype="float32") x = resample_poly(x, 16000, sr).astype(np.float32) # hand whisper an array: avoids PyAV version clashes segs, _ = m.transcribe(x, word_timestamps=True, language="en") ww = [w for s in segs for w in (s.words or [])] out = [] for b, tm in zip(beats, timing): a, z = tm["start"], tm["start"] + tm["dur"] inside = [w for w in ww if w.start >= a - 0.15 and w.end <= z + 0.25] toks = b["say"].split() total = max(1, sum(len(re.sub(r"\W", "", t)) or 1 for t in toks)) # whisper clock: cumulative chars -> time (even spacing if whisper heard nothing here) xs, ts = [0], [inside[0].start if inside else a] c = 0 for w in inside: c += len(re.sub(r"\W", "", w.word)) or 1 xs.append(c) ts.append(w.end) if not inside: xs, ts = [0, total], [a, z] scale = xs[-1] / total if xs[-1] else 1 cur, row = 0, [] for t in toks: n = len(re.sub(r"\W", "", t)) or 1 s = float(np.interp(cur * scale, xs, ts)) e = float(np.interp((cur + n) * scale, xs, ts)) row.append({"w": t, "s": round(s, 3), "e": round(max(e, s + 0.08), 3)}) cur += n out.append(row) return out if __name__ == "__main__": import sys story = json.load(open(sys.argv[1], encoding="utf-8")) p, tm, total = synth(story["beats"], "assets/audio", story.get("voice", "af_heart")) print(p, round(total, 2), tm)examples/01-first-bug/CREDITS.txt1.0 KB5f7b62de9c0eupstream ↗
hopper.jpg: File:Commodore Grace M. Hopper, USN (covered) head and shoulders crop.jpg | James S. Davis | Public domain | https://commons.wikimedia.org/wiki/File:Commodore_Grace_M._Hopper,_USN_(covered)_head_and_shoulders_crop.jpg edison.jpg: File:Portrait of Thomas Edison. (409fa731e3364ed99012ccedfdce3e28).jpg | Unknown authorUnknown author | Public domain | https://commons.wikimedia.org/wiki/File:Portrait_of_Thomas_Edison._(409fa731e3364ed99012ccedfdce3e28).jpg tape.jpg: File:First Computer Bug, 1947.jpg | Courtesy of the Naval Surface Warfare Center, Dahlgren, VA., 1988. | Public domain | https://commons.wikimedia.org/wiki/File:First_Computer_Bug,_1947.jpg line.jpg: File:First Computer Bug, 1947.jpg | Courtesy of the Naval Surface Warfare Center, Dahlgren, VA., 1988. | Public domain | https://commons.wikimedia.org/wiki/File:First_Computer_Bug,_1947.jpg page.jpg: File:First Computer Bug, 1947.jpg | Courtesy of the Naval Surface Warfare Center, Dahlgren, VA., 1988. | Public domain | https://commons.wikimedia.org/wiki/File:First_Computer_Bug,_1947.jpgexamples/01-first-bug/story.json2.8 KB44b61cfa40dfupstream ↗
{ "title": "The First Computer Bug", "slug": "first-computer-bug", "format": "9:16", "voice": "af_heart", "speed": 1.0, "music": "bed", "images": { "tape": {"commons": "File:First Computer Bug, 1947.jpg", "crop": [0.115, 0.535, 0.45, 0.715], "cut": false, "accent": "mustard", "size": 1100, "dots": false}, "line": {"commons": "File:First Computer Bug, 1947.jpg", "crop": [0.13, 0.69, 0.79, 0.785], "cut": false, "accent": null, "size": 1200, "dots": false}, "page": {"commons": "File:First Computer Bug, 1947.jpg", "cut": false, "accent": "red", "size": 1200, "dots": false}, "hopper": {"commons": "File:Commodore Grace M. Hopper, USN (covered) head and shoulders crop.jpg", "cut": true, "accent": "red"}, "edison": {"commons": "File:Portrait of Thomas Edison. (409fa731e3364ed99012ccedfdce3e28).jpg", "cut": true, "accent": "teal"} }, "beats": [ {"say": "September ninth, 1947. Harvard University.", "kind": "document", "bg": "mustard", "head": "OPERATIONS LOG", "lines": ["DATE: 9 SEPT 1947", "MACHINE: MARK II", "STATUS: RUNNING"], "label": "09.09.1947"}, {"say": "Mid-afternoon, one of the world's first computers starts making errors.", "kind": "number", "bg": "red", "number": "15:45", "sub": "ERRORS", "stamp": "FAULT"}, {"say": "The team opens Panel F and checks the relays, one by one.", "kind": "timeline", "bg": "teal", "dates": ["#68", "#69", "#70", "#71"], "active": 2, "label": "PANEL F"}, {"say": "In relay number seventy, they find it.", "kind": "number", "bg": "navy", "number": "#70", "stamp": "FOUND", "scraps": 3}, {"say": "A moth.", "kind": "photo", "bg": "newsprint", "photo": "tape", "label": "A MOTH", "burst": true, "burst_color": "mustard"}, {"say": "They tape it into the logbook, and write one line.", "kind": "photo", "bg": "kraft", "photo": "page", "tag": "THE REAL LOGBOOK PAGE"}, {"say": "First actual case of bug being found.", "kind": "photo", "bg": "mustard", "photo": "line", "label": "THE NOTE", "stamp": "1947", "stamp_on": "found"}, {"say": "Grace Hopper told this story for decades, and the word stuck.", "kind": "hero", "bg": "red", "hero": "hopper", "label": "GRACE HOPPER", "tag": "U.S. NAVY", "burst": true}, {"say": "But engineers were already calling glitches bugs in Thomas Edison's time.", "kind": "timeline", "bg": "teal", "dates": ["1878", "1947", "TODAY"], "active": 0, "hero": "edison", "label": "EDISON"}, {"say": "The moth just made it literal.", "kind": "hero", "bg": "newsprint", "hero": "tape", "circle": true, "label": "LITERALLY"}, {"say": "That page is now kept at the Smithsonian.", "kind": "photo", "bg": "navy", "photo": "page", "stamp": "SMITHSONIAN", "stamp_on": "Smithsonian"}, {"say": "Seventy-nine years later, we are still debugging.", "kind": "number", "bg": "red", "number": "79 YEARS", "sub": "STILL DEBUGGING"} ] }examples/02-apollo-13/CREDITS.txt938 B751328f835a3upstream ↗
sm.jpg: File:Apollo 13 SM.jpg | NASA Astronaut (either James A. Lovell, John L. Swigert, or Fred W. Haise) | Public domain | https://commons.wikimedia.org/wiki/File:Apollo_13_SM.jpg crew.jpg: File:Apollo 13 Prime Crew.jpg | NASA | Public domain | https://commons.wikimedia.org/wiki/File:Apollo_13_Prime_Crew.jpg swigert.jpg: File:Jack Swigert.jpg | NASA | Public domain | https://commons.wikimedia.org/wiki/File:Jack_Swigert.jpg lm.jpg: File:Apollo 13 Lunar Module after separation from CSM (AS13-59-8562).jpg | NASA | Public domain | https://commons.wikimedia.org/wiki/File:Apollo_13_Lunar_Module_after_separation_from_CSM_(AS13-59-8562).jpg mailbox.jpg: File:Apollo 13 Mailbox at Mission Control.jpg | NASA | Public domain | https://commons.wikimedia.org/wiki/File:Apollo_13_Mailbox_at_Mission_Control.jpg splash.jpg: File:Apollo13 splashdown.jpg | NASA | Public domain | https://commons.wikimedia.org/wiki/File:Apollo13_splashdown.jpgexamples/02-apollo-13/story.json2.9 KBb05060f2846aupstream ↗
{ "title": "Apollo 13", "slug": "apollo-13", "format": "9:16", "voice": "af_heart", "music": "bed", "seed": 21, "images": { "sm": {"commons": "File:Apollo 13 SM.jpg", "cut": false, "accent": "red", "size": 1100}, "crew": {"commons": "File:Apollo 13 Prime Crew.jpg", "cut": true, "accent": "mustard", "size": 1100}, "swigert": {"commons": "File:Jack Swigert.jpg", "cut": true, "accent": "red", "crop": [0.12, 0.05, 0.9, 0.75]}, "lm": {"commons": "File:Apollo 13 Lunar Module after separation from CSM (AS13-59-8562).jpg", "cut": false, "accent": "teal", "crop": [0.1, 0.12, 0.9, 0.88], "size": 1000}, "mailbox": {"commons": "File:Apollo 13 Mailbox at Mission Control.jpg", "cut": false, "accent": "mustard", "size": 1100}, "splash": {"commons": "File:Apollo13 splashdown.jpg", "cut": false, "accent": "red", "size": 1000} }, "beats": [ {"say": "April thirteenth, 1970. Two hundred thousand miles from Earth.", "kind": "number", "bg": "navy", "number": "200,000 MI", "sub": "13.04.1970"}, {"say": "An oxygen tank on Apollo Thirteen explodes.", "kind": "photo", "bg": "red", "photo": "sm", "label": "APOLLO 13", "stamp": "EXPLOSION", "stamp_on": "explodes"}, {"say": "Jack Swigert radios: Okay, Houston, we've had a problem here.", "kind": "quote", "bg": "mustard", "hero": "swigert", "quote": "Houston, we've had a problem here.", "by": "Jack Swigert, 1970"}, {"say": "The moon landing is off. Now the only goal is getting three men home.", "kind": "hero", "bg": "teal", "hero": "crew", "label": "LOVELL · SWIGERT · HAISE", "label_size": 70, "stamp": "GET HOME", "burst": true}, {"say": "They power down the command module and move into the lunar lander.", "kind": "photo", "bg": "mustard", "photo": "lm", "label": "AQUARIUS", "tag": "THE LUNAR MODULE"}, {"say": "A lifeboat built for two people, now carrying three.", "kind": "number", "bg": "kraft", "number": "2 → 3", "sub": "BUILT FOR TWO", "stamp": "OVERLOAD"}, {"say": "Then the carbon dioxide starts rising.", "kind": "number", "bg": "red", "number": "CO2", "sub": "RISING", "scraps": 3}, {"say": "Engineers on the ground build a fix from plastic bags, cardboard and tape.", "kind": "document", "bg": "mustard", "head": "THE FIX", "lines": ["1. PLASTIC BAGS", "2. CARDBOARD", "3. DUCT TAPE"], "mark": 2}, {"say": "They call it the mailbox. The crew copies it, piece by piece.", "kind": "photo", "bg": "teal", "photo": "mailbox", "label": "THE MAILBOX", "tag": "MISSION CONTROL, 1970"}, {"say": "April seventeenth. Splashdown in the South Pacific.", "kind": "photo", "bg": "navy", "photo": "splash", "label": "17.04.1970", "stamp": "HOME", "stamp_on": "Pacific"}, {"say": "Apollo Thirteen never touched the moon.", "kind": "timeline", "bg": "newsprint", "dates": ["LAUNCH", "MOON", "HOME"], "active": 2, "label": "NO LANDING"}, {"say": "It is still called a successful failure.", "kind": "quote", "bg": "red", "quote": "A successful failure.", "by": "Apollo 13, 1970"} ] }examples/03-mona-lisa/CREDITS.txt920 Bdc112a7ece27upstream ↗
wall.jpg: File:Mona Lisa stolen-1911.jpg | Unknown authorUnknown author | Public domain | https://commons.wikimedia.org/wiki/File:Mona_Lisa_stolen-1911.jpg peruggia.jpg: File:Vincenzo peruggia.jpg | Unknown authorUnknown author | Public domain | https://commons.wikimedia.org/wiki/File:Vincenzo_peruggia.jpg mona.jpg: File:Mona Lisa, by Leonardo da Vinci, from C2RMF retouched.jpg | Leonardo da Vinci | Public domain | https://commons.wikimedia.org/wiki/File:Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg picasso.jpg: File:Pablo Picasso in his Montmartre studio, 1908.jpg | Gelett Burgess | Public domain | https://commons.wikimedia.org/wiki/File:Pablo_Picasso_in_his_Montmartre_studio,_1908.jpg monacut.jpg: File:Mona Lisa, by Leonardo da Vinci, from C2RMF retouched.jpg | Leonardo da Vinci | Public domain | https://commons.wikimedia.org/wiki/File:Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpgexamples/03-mona-lisa/story.json2.7 KB23b7ec5ed81dupstream ↗
{ "title": "The Mona Lisa Theft", "slug": "mona-lisa-theft", "format": "9:16", "voice": "af_heart", "music": "bed", "seed": 33, "images": { "wall": {"commons": "File:Mona Lisa stolen-1911.jpg", "cut": false, "accent": "red", "size": 900}, "peruggia": {"commons": "File:Vincenzo peruggia.jpg", "cut": true, "accent": "red"}, "mona": {"commons": "File:Mona Lisa, by Leonardo da Vinci, from C2RMF retouched.jpg", "cut": false, "accent": "mustard", "size": 1000}, "picasso": {"commons": "File:Pablo Picasso in his Montmartre studio, 1908.jpg", "cut": true, "accent": "teal", "crop": [0.47, 0.28, 1.0, 0.86], "contrast": 1.3}, "monacut": {"commons": "File:Mona Lisa, by Leonardo da Vinci, from C2RMF retouched.jpg", "cut": true, "accent": "red", "size": 1000} }, "beats": [ {"say": "August 1911. The Louvre, Paris.", "kind": "map", "bg": "mustard", "map": {"country": "France", "pin": [2.34, 48.86], "place": "THE LOUVRE", "sea": "teal"}, "label": "08.1911"}, {"say": "A painter comes to sketch the Mona Lisa, and finds an empty wall.", "kind": "photo", "bg": "red", "photo": "wall", "label": "AN EMPTY WALL", "tag": "THE LOUVRE, 1911"}, {"say": "The painting has been gone for more than a day.", "kind": "number", "bg": "navy", "number": "24+ HRS", "sub": "MISSING", "stamp": "STOLEN"}, {"say": "Police question hundreds of people, even Pablo Picasso.", "kind": "hero", "bg": "teal", "hero": "picasso", "label": "PICASSO", "stamp": "QUESTIONED", "stamp_on": "Picasso"}, {"say": "But the thief is a Louvre worker named Vincenzo Peruggia.", "kind": "hero", "bg": "newsprint", "hero": "peruggia", "label": "THE THIEF", "tag": "VINCENZO PERUGGIA", "circle": true}, {"say": "He walks out with it, hidden under his work smock.", "kind": "document", "bg": "kraft", "head": "POLICE REPORT", "lines": ["ITEM: MONA LISA", "EXIT: STAFF STAIRS", "COVER: WORK SMOCK"], "redact": [1]}, {"say": "For more than two years, the Mona Lisa sits in his room in Paris.", "kind": "number", "bg": "red", "number": "2 YEARS", "hero": "mona"}, {"say": "In 1913, he takes it to an art dealer in Florence.", "kind": "map", "bg": "mustard", "map": {"highlight": ["France", "Italy"], "pin": [11.25, 43.77], "place": "FLORENCE", "sea": "navy"}, "label": "1913"}, {"say": "The dealer calls the police.", "kind": "hero", "bg": "navy", "hero": "peruggia", "stamp": "ARRESTED", "stamp_on": "police"}, {"say": "Before the theft, it was not the world's most famous painting.", "kind": "photo", "bg": "newsprint", "photo": "mona", "stamp": "NOT YET", "stamp_on": "famous"}, {"say": "After it, it was.", "kind": "hero", "bg": "red", "hero": "monacut", "label": "MOST FAMOUS", "burst": true} ] }examples/04-eniac/CREDITS.txt868 Bc4fadc7d51deupstream ↗
room.jpg: File:Classic shot of the ENIAC.jpg | Unidentified U.S. Army photographer | Public domain | https://commons.wikimedia.org/wiki/File:Classic_shot_of_the_ENIAC.jpg two.jpg: File:Two women operating ENIAC (full resolution).jpg | Unidentified U.S. Army photographer | Public domain | https://commons.wikimedia.org/wiki/File:Two_women_operating_ENIAC_(full_resolution).jpg snyder.jpg: File:Glen Beck and Betty Snyder program the ENIAC in building 328 at the Ballistic Research Laboratory.jpg | Unknown authorUnknown author | Public domain | https://commons.wikimedia.org/wiki/File:Glen_Beck_and_Betty_Snyder_program_the_ENIAC_in_building_328_at_the_Ballistic_Research_Laboratory.jpg jennings.jpg: File:Betty Jennings (Mrs. Bartik).jpg | Unidentified U.S. Army photographer | Public domain | https://commons.wikimedia.org/wiki/File:Betty_Jennings_(Mrs._Bartik).jpgexamples/04-eniac/story.json2.9 KB5c5b6ef9e3e2upstream ↗
{ "title": "ENIAC's Six Programmers", "slug": "eniac-programmers", "format": "9:16", "voice": "af_heart", "music": "bed", "seed": 44, "images": { "room": { "commons": "File:Classic shot of the ENIAC.jpg", "cut": false, "accent": "red", "size": 1000 }, "two": { "commons": "File:Two women operating ENIAC (full resolution).jpg", "cut": false, "accent": "mustard", "size": 1100 }, "snyder": { "commons": "File:Glen Beck and Betty Snyder program the ENIAC in building 328 at the Ballistic Research Laboratory.jpg", "cut": false, "accent": "teal", "size": 1000 }, "jennings": { "commons": "File:Betty Jennings (Mrs. Bartik).jpg", "cut": false, "accent": "mustard", "crop": [ 0.0, 0.0, 1.0, 0.62 ], "size": 1000 } }, "beats": [ { "say": "February 1946. Philadelphia.", "kind": "photo", "bg": "mustard", "photo": "room", "label": "02.1946" }, { "say": "The US Army shows off ENIAC, a computer that weighs thirty tons.", "kind": "number", "bg": "red", "number": "30 TONS", "sub": "ENIAC" }, { "say": "Newspapers call it a giant brain.", "kind": "document", "bg": "newsprint", "head": "THE PRESS", "lines": [ "\"GIANT BRAIN\"", "ARMY UNVEILS", "ELECTRONIC COMPUTER" ], "mark": 0 }, { "say": "But six women had programmed it, with no manuals, using cables and switches.", "kind": "photo", "bg": "teal", "photo": "two", "label": "SIX PROGRAMMERS", "label_size": 90 }, { "say": "Kay McNulty, Jean Jennings, Betty Snyder, Marlyn Wescoff, Fran Bilas and Ruth Lichterman.", "kind": "document", "bg": "mustard", "head": "THE TEAM", "lines": [ "KAY McNULTY", "JEAN JENNINGS", "BETTY SNYDER", "MARLYN WESCOFF", "FRAN BILAS", "RUTH LICHTERMAN" ] }, { "say": "At the big demonstration, none of them were introduced.", "kind": "photo", "bg": "red", "tag": "JEAN JENNINGS", "stamp": "NOT INTRODUCED", "stamp_size": 76, "stamp_on": "introduced", "photo": "jennings" }, { "say": "Decades later, a researcher was told they were just models posing with the machine.", "kind": "photo", "bg": "navy", "photo": "snyder", "stamp": "MODELS?", "tag": "BETTY SNYDER AT THE ENIAC" }, { "say": "Their names stayed almost unknown for half a century.", "kind": "timeline", "bg": "kraft", "dates": [ "1946", "1970", "1997" ], "active": 2, "label": "50 YEARS" }, { "say": "In 1997, all six entered the Women in Technology Hall of Fame.", "kind": "number", "bg": "teal", "number": "1997", "sub": "HALL OF FAME" }, { "say": "Every programmer today works in a job they helped invent.", "kind": "photo", "bg": "red", "label": "THE FIRST PROGRAMMERS", "label_size": 76, "burst": true, "photo": "two" } ] }LICENSE1.1 KB49b78646fd1aupstream ↗
MIT License Copyright (c) 2026 Bashar Mokdad (Web Lab) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.requirements.txt114 B2ee8f1139ba3upstream ↗
kokoro-onnx>=0.4 soundfile faster-whisper>=1.0 rembg[cpu]>=2.0 opencv-python-headless pillow numpy scipy requestssetup.ps11.7 KBfc8d79435407upstream ↗
# papercut setup for Windows: venv + libraries + Kokoro voice model + map data and animation library. $ErrorActionPreference = "Stop" $root = $PSScriptRoot foreach ($tool in "python", "node", "npx", "ffmpeg") { if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { throw "$tool is not installed or not on PATH" } } if (-not (Test-Path "$root\.venv")) { python -m venv "$root\.venv" } & "$root\.venv\Scripts\python.exe" -m pip install -q -U pip & "$root\.venv\Scripts\python.exe" -m pip install -q -r "$root\requirements.txt" $models = Join-Path $HOME ".papercut\kokoro" New-Item -ItemType Directory -Force $models | Out-Null $rel = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0" foreach ($f in "kokoro-v1.0.onnx", "voices-v1.0.bin") { if (-not (Test-Path "$models\$f")) { Write-Host "downloading $f"; curl.exe -sL -o "$models\$f" "$rel/$f" } } $data = "$root\engine\data" New-Item -ItemType Directory -Force $data | Out-Null $libs = @{ "gsap.min.js" = "https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js" "d3-array.min.js" = "https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-array.min.js" "d3-geo.min.js" = "https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-geo.min.js" "topojson-client.min.js" = "https://cdn.jsdelivr.net/npm/[email protected]/dist/topojson-client.min.js" "countries-110m.json" = "https://cdn.jsdelivr.net/npm/[email protected]/countries-110m.json" } foreach ($k in $libs.Keys) { if (-not (Test-Path "$data\$k")) { curl.exe -sL -o "$data\$k" $libs[$k] } } npx --yes hyperframes --version | Out-Null Write-Host "papercut is ready. Try: .venv\Scripts\python.exe engine\papercut.py make examples\01-first-bug\story.json --render"setup.sh1.4 KB09bfc1d5852cupstream ↗
#!/usr/bin/env bash # papercut setup for macOS / Linux: venv + libraries + Kokoro voice model + map data and animation library. set -euo pipefail root="$(cd "$(dirname "$0")" && pwd)" for tool in python3 node npx ffmpeg curl; do command -v "$tool" >/dev/null || { echo "$tool is not installed"; exit 1; } done [ -d "$root/.venv" ] || python3 -m venv "$root/.venv" "$root/.venv/bin/python" -m pip install -q -U pip "$root/.venv/bin/python" -m pip install -q -r "$root/requirements.txt" models="$HOME/.papercut/kokoro" mkdir -p "$models" rel="https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0" for f in kokoro-v1.0.onnx voices-v1.0.bin; do [ -f "$models/$f" ] || { echo "downloading $f"; curl -sL -o "$models/$f" "$rel/$f"; } done data="$root/engine/data" mkdir -p "$data" get() { [ -f "$data/$1" ] || curl -sL -o "$data/$1" "$2"; } get gsap.min.js https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js get d3-array.min.js https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-array.min.js get d3-geo.min.js https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-geo.min.js get topojson-client.min.js https://cdn.jsdelivr.net/npm/[email protected]/dist/topojson-client.min.js get countries-110m.json https://cdn.jsdelivr.net/npm/[email protected]/countries-110m.json npx --yes hyperframes --version >/dev/null echo "papercut is ready. Try: .venv/bin/python engine/papercut.py make examples/01-first-bug/story.json --render"
Plain-text copy, no login: /skill-bundles/basharmokdad-papercut.md