# papercut

Adopt this skill: https://proskills.md/skills/other/basharmokdad-papercut

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

## File: SKILL.md
size: 6840
sha: a6640c2906b92ee79deb7504411badcbe76f4984
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/SKILL.md

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

## File: README.md
size: 4054
sha: a2fb3d0604ec65feac6905b23f2fb118ba78ae93
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/README.md

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

![Frames from the four example films](docs/frames.jpg)

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

## File: .gitattributes
size: 17
sha: dfdb8b771ce07609491fa2e83698969fd917a135
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/.gitattributes

```text
*.sh text eol=lf
```

## File: .gitignore
size: 171
sha: 1ff78fd8c917006569e36e4f8899ca2f9bbe19c0
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/.gitignore

```text
.venv/
_test/
__pycache__/
engine/data/*.js
engine/data/*.json
**/project/
**/sources/
**/snaps*/
**/*.mp4
**/pieces.jpg
**/sheet.png
**/sheet.png.json
engine/CREDITS.txt
```

## File: CREDITS.md
size: 1324
sha: 0ddc959608ad9997616a438804e74568e134c733
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/CREDITS.md

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

## File: docs/demo-hopper.gif
size: 3582269
sha: 220e202c4840cdb635b9c2171d7ed4dfbf5dc270
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/docs/demo-hopper.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/basharmokdad/papercut/blob/HEAD/docs/demo-hopper.gif

## File: docs/demo-map.gif
size: 3798566
sha: 9459b38ec710fe2cebe1f7af87700c0a060235a6
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/docs/demo-map.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/basharmokdad/papercut/blob/HEAD/docs/demo-map.gif

## File: docs/frames.jpg
size: 352832
sha: ae333e30e32b4ff5ff337fc9b95c1e9ac208fe6a
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/docs/frames.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/basharmokdad/papercut/blob/HEAD/docs/frames.jpg

## File: engine/build.py
size: 44173
sha: 7376eb336d1b9c8f250a7bebdb3141683aa886d7
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/build.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/basharmokdad/papercut/blob/HEAD/engine/build.py

## File: engine/cutout.py
size: 6363
sha: 8b604b170e3e6973e02d579aaa96715bde1e9b58
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/cutout.py

```text
"""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])
```

## File: engine/fetch.py
size: 5354
sha: c231fee35e27a158b288487f9478a21c45f8b775
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fetch.py

```text
"""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))
```

## File: engine/fonts/Anton-Regular.ttf
size: 170812
sha: 4d65707db9d8663ccfa99cd28cd3a4e0025be178
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/Anton-Regular.ttf

Content is not inlined in the catalog. Read the upstream file: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/Anton-Regular.ttf

## File: engine/fonts/ArchivoBlack-Regular.ttf
size: 90988
sha: 82d07de3b84ed4428f6a302b70f4b555148dfeb0
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/ArchivoBlack-Regular.ttf

Content is not inlined in the catalog. Read the upstream file: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/ArchivoBlack-Regular.ttf

## File: engine/fonts/LICENSE-PermanentMarker.txt
size: 11358
sha: d645695673349e3947e8e5ae42332d0ac3164cd7
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/LICENSE-PermanentMarker.txt

```text

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

## File: engine/fonts/LICENSE-SpecialElite.txt
size: 11358
sha: d645695673349e3947e8e5ae42332d0ac3164cd7
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/LICENSE-SpecialElite.txt

```text

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

## File: engine/fonts/OFL-Anton.txt
size: 4391
sha: efd0dd80c2deafa962d86926d1f445a9aa7407c4
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/OFL-Anton.txt

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

## File: engine/fonts/OFL-ArchivoBlack.txt
size: 4399
sha: 1474543356491970a8fd61f9dc8e2bc682cf9126
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/OFL-ArchivoBlack.txt

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

## File: engine/fonts/PermanentMarker-Regular.ttf
size: 74632
sha: 3218fc5b3d395c3a3fc207fdf17a27bb32af3242
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/PermanentMarker-Regular.ttf

Content is not inlined in the catalog. Read the upstream file: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/PermanentMarker-Regular.ttf

## File: engine/fonts/SpecialElite-Regular.ttf
size: 166180
sha: 6654876f95f11cb2b55c7c4254727ef68dbea74c
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/SpecialElite-Regular.ttf

Content is not inlined in the catalog. Read the upstream file: https://github.com/basharmokdad/papercut/blob/HEAD/engine/fonts/SpecialElite-Regular.ttf

## File: engine/paper.py
size: 6033
sha: c852c13f056cd9d477735eed7abdae06ff2761df
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/paper.py

```text
"""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 t
```

## File: engine/papercut.py
size: 7746
sha: 4a9e5b3ccc3c7374bd9bbd63093c6b9a3296519a
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/papercut.py

```text
"""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)
```

## File: engine/sfx.py
size: 4750
sha: c57043b68c0c276ddaab4b95cbd29eb6a3e828a9
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/sfx.py

```text
"""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)
```

## File: engine/voice.py
size: 3770
sha: 836d7e21e9c846b154b15301a51c24515285be23
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/engine/voice.py

```text
"""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)
```

## File: examples/01-first-bug/CREDITS.txt
size: 1068
sha: 5f7b62de9c0eb5f2e8e0f6db751c67f8a0589b58
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/examples/01-first-bug/CREDITS.txt

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

## File: examples/01-first-bug/story.json
size: 2902
sha: 44b61cfa40dff47b10a1d3269ac9235d94d12f2e
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/examples/01-first-bug/story.json

```text
{
  "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"}
  ]
}
```

## File: examples/02-apollo-13/CREDITS.txt
size: 938
sha: 751328f835a331f0f056855d2e62c78bc4b353b6
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/examples/02-apollo-13/CREDITS.txt

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

## File: examples/02-apollo-13/story.json
size: 3013
sha: b05060f2846a38c9a54ca159b35559663475d6a9
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/examples/02-apollo-13/story.json

```text
{
  "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"}
  ]
}
```

## File: examples/03-mona-lisa/CREDITS.txt
size: 920
sha: dc112a7ece278f194d36994d69793b69aefbee17
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/examples/03-mona-lisa/CREDITS.txt

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

## File: examples/03-mona-lisa/story.json
size: 2726
sha: 23b7ec5ed81d6fb9ac9aae31242ae33dbeaa7147
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/examples/03-mona-lisa/story.json

```text
{
  "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}
  ]
}
```

## File: examples/04-eniac/CREDITS.txt
size: 868
sha: c4fadc7d51defe5279c499bb22de5749cd1acd8f
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/examples/04-eniac/CREDITS.txt

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

## File: examples/04-eniac/story.json
size: 2932
sha: 5c5b6ef9e3e263ca283f3e642a921e4ccaae3171
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/examples/04-eniac/story.json

```text
{
 "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"
  }
 ]
}
```

## File: LICENSE
size: 1080
sha: 49b78646fd1afbcfd9c2ffd5948eff1152977f0d
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/LICENSE

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

## File: requirements.txt
size: 114
sha: 2ee8f1139ba37829923be19fbd7bf59a5b36dda7
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/requirements.txt

```text
kokoro-onnx>=0.4
soundfile
faster-whisper>=1.0
rembg[cpu]>=2.0
opencv-python-headless
pillow
numpy
scipy
requests
```

## File: setup.ps1
size: 1734
sha: fc8d79435407be24a67e5cb65f6d4b4c0ee6fe96
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/setup.ps1

```text
# 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/gsap@3.14.2/dist/gsap.min.js"
    "d3-array.min.js" = "https://cdn.jsdelivr.net/npm/d3-array@3.2.4/dist/d3-array.min.js"
    "d3-geo.min.js" = "https://cdn.jsdelivr.net/npm/d3-geo@3.1.1/dist/d3-geo.min.js"
    "topojson-client.min.js" = "https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/dist/topojson-client.min.js"
    "countries-110m.json" = "https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/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"
```

## File: setup.sh
size: 1461
sha: 09bfc1d5852c91381d81412b799aa96576d2589c
upstream: https://github.com/basharmokdad/papercut/blob/HEAD/setup.sh

```text
#!/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/gsap@3.14.2/dist/gsap.min.js
get d3-array.min.js https://cdn.jsdelivr.net/npm/d3-array@3.2.4/dist/d3-array.min.js
get d3-geo.min.js https://cdn.jsdelivr.net/npm/d3-geo@3.1.1/dist/d3-geo.min.js
get topojson-client.min.js https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/dist/topojson-client.min.js
get countries-110m.json https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/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"
```
