⏳ This skill is pending AI review.

Scores will appear once the review pipeline completes.

version unknown

papercut

@basharmokdad⭐ 2 stars

Make a finished paper-collage documentary short (the Vox-style halftone cut-out look) from one topic, for free, on your own computer. Claude verifies the facts, writes the script, finds public-domain archive photos, and the engine cuts them out, voices the narration, animates every piece in stop-motion, adds paper sound effects and captions, and renders the MP4. Use for "make a collage video about X", "Vox-style explainer", "paper cut-out documentary", "halftone collage short", or any history, tech or true-story short where no footage exists.

Choose how to use this skill

You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.

1. Native installer

This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.

Do not guess an installer command or replace an existing version without reviewing the diff.

2. Complete package recommended

Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.

No complete ProSkills package is published for this listing yet.

3. Prompt-only

Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.

Need only the instruction file?

Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.

No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.

—/10

// RATINGS

⭐GitHub Stars
⭐ 2 on GitHubGitHub ↗

New / niche

🟢ProSkills Score
—
📍

Not yet listed on ClawHub or SkillsMP

// README

papercut

Vox-style paper-collage documentaries, made by Claude, for free, on your own computer.

One topic in, one finished vertical short out: halftone cut-outs, torn paper, rubber stamps, maps, typewriter documents, stop-motion on twos, paper sound effects, captions and a voice. No video model, no image model, no credits.

Frames from the four example films

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.

git clone https://github.com/basharmokdad/papercut
cd papercut
./setup.sh          # macOS / Linux
# or on Windows:
powershell -ExecutionPolicy Bypass -File setup.ps1

Setup makes a Python venv in .venv, installs the libraries, downloads the Kokoro voice model (~330 MB) to ~/.papercut/kokoro, and fetches the map data and animation library into engine/data.

To use it as a Claude Code skill, link or copy the folder into your skills folder:

ln -s "$(pwd)" ~/.claude/skills/papercut

Then ask Claude: "Make a papercut video about the first computer bug."

Use it by hand

mkdir my-film && cd my-film
../.venv/bin/python ../engine/papercut.py search "Grace Hopper" sheet.png   # find photos, look at sheet.png
# write story.json (see examples/ and SKILL.md)
../.venv/bin/python ../engine/papercut.py make story.json                   # build
../.venv/bin/python ../engine/papercut.py snap story.json 3.2,7.5           # check frames
../.venv/bin/python ../engine/papercut.py make story.json --render          # render the MP4

Each film is one story.json: the images and a list of beats. A beat is one line of narration plus one scene: hero, photo, duo, number, document, timeline, quote or map. The full reference and the picture rules are in SKILL.md.

Examples

FilmScenes it shows off
The first computer bugthe real 1947 logbook, ransom-letter numbers, timeline
Apollo 13NASA photos, quotes, document checklist
The Mona Lisa theftpaper maps with pins, police report with a redaction bar
ENIAC's six programmerstyped 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. Open-source parts and their licences are listed in CREDITS.md. Code: MIT.

// HOW IT'S BUILT

// FILES

35 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.

  • SKILL.md6.7 KBa6640c2906b9upstream ↗
    ---
    name: papercut
    description: Make a finished paper-collage documentary short (the Vox-style halftone cut-out look) from one topic, for free, on your own computer. Claude verifies the facts, writes the script, finds public-domain archive photos, and the engine cuts them out, voices the narration, animates every piece in stop-motion, adds paper sound effects and captions, and renders the MP4. Use for "make a collage video about X", "Vox-style explainer", "paper cut-out documentary", "halftone collage short", or any history, tech or true-story short where no footage exists.
    ---
    
    # papercut
    
    One topic in, one finished 30-60 s documentary out. No video model, no image model, no credits:
    
    | Part | Tool | Licence |
    |---|---|---|
    | Script, facts, picture choices | Claude | your plan |
    | Archive photos | Wikimedia Commons, public domain / CC0 only | free to use |
    | Cut-outs | rembg (isnet) + our halftone + scissor edge | MIT / Apache-2.0 |
    | Voice | Kokoro-82M, on the CPU | Apache-2.0 |
    | Word timing for captions | faster-whisper | MIT |
    | Paper, foley, music bed | generated in code from a seed | ours, MIT |
    | Animation + render | HyperFrames (HTML to video) | Apache-2.0 |
    | Maps | Natural Earth via world-atlas + d3-geo | public domain / ISC |
    | Fonts | Anton, Archivo Black, Special Elite, Permanent Marker | OFL / Apache-2.0 |
    
    Paths (relative to this skill's own folder, the one this SKILL.md is in):
    - engine: `<skill>/engine/papercut.py`
    - python: `<skill>/.venv/Scripts/python.exe` on Windows, `<skill>/.venv/bin/python` on macOS / Linux (made by setup)
    
    Make one folder per film and run every command from inside it, e.g. `"<python>" "<skill>/engine/papercut.py" make story.json`.
    
    ## The workflow (run it in this order, stop where it says)
    
    ### 1. Facts first
    
    Search the topic. Every date, name and number in the script needs two independent sources. If sources disagree, write around it ("August 1911", not "August 21"). Never invent a quote: use the documented wording, or none.
    
    ### 2. Script (beats)
    
    - 10-14 beats, 30-45 s. One sentence (or half of one) per beat, one visual idea per beat.
    - Cold open on a date and a place: "September ninth, 1947. Harvard University."
    - Calm documentary voice, short declaratives. Spell numbers the way they should be spoken ("Seventy-nine years later").
    - End on a short line that lands: "After it, it was." / "We are still debugging."
    
    ### 3. Pictures
    
    ```
    <python> <skill>/engine/papercut.py search "Grace Hopper" sheet.png
    ```
    
    Open `sheet.png` and pick by looking, not by title. Rules learned the hard way:
    
    - People: `"cut": true` (background removed). The face must be well lit and at least ~400 px tall in the source. Dark or busy photos cut badly: crop tight around the person (`"crop": [l, t, r, b]` as fractions) or use `"cut": false`.
    - Machines, rooms, scenes, space photos: `"cut": false` (a printed photo with tape).
    - Documents and handwriting: `"cut": false, "dots": false` (clean print, halftone would destroy the writing).
    - Never show a photo of the wrong object with the right caption (a Mark I is not a Mark II). If no true photo exists, use a `document`, `number` or `timeline` beat instead.
    
    ### 4. story.json
    
    ```json
    {
      "title": "The First Computer Bug", "slug": "first-computer-bug",
      "format": "9:16", "voice": "af_heart", "music": "bed", "seed": 11,
      "images": {
        "hopper": {"commons": "File:Commodore Grace M. Hopper, USN (covered) head and shoulders crop.jpg", "cut": true, "accent": "red"},
        "page":   {"file": "sources/log.jpg", "cut": false, "dots": false, "accent": "red", "size": 1200}
      },
      "beats": [
        {"say": "September ninth, 1947. Harvard University.", "kind": "document", "bg": "mustard",
         "head": "OPERATIONS LOG", "lines": ["DATE: 9 SEPT 1947", "MACHINE: MARK II"], "label": "09.09.1947"}
      ]
    }
    ```
    
    Image keys: `commons` (exact `File:` title) or `file` (local path), `cut`, `dots`, `crop`, `accent` (red, mustard, teal, null), `size`, `contrast`.
    
    Beat kinds (every beat also takes `bg`, `scraps`, `burst`, `transition`):
    
    | kind | fields | use for |
    |---|---|---|
    | `hero` | `hero`, `label`, `tag`, `stamp`, `stamp_on`, `circle` | a person or object, big |
    | `photo` | `photo`, `label`, `tag`, `stamp`, `stamp_on` | a scene, a document photo |
    | `duo` | `left`, `right`, `string`, `label` | two people, a connection |
    | `number` | `number`, `sub`, `stamp`, `hero` | a date, an amount, a count (ransom letters) |
    | `document` | `head`, `lines`, `redact`, `mark`, `stamp`, `hero`, `label` | typed text, lists, names |
    | `timeline` | `dates`, `active`, `hero`, `label` | before / after, a span of years |
    | `quote` | `quote`, `by`, `hero` | a real, sourced quote |
    | `map` | `map: {country | highlight[], pin: [lon, lat], place, sea}`, `label`, `stamp` | where it happened |
    
    `bg`: mustard, red, teal, navy, newsprint, kraft, cream, pink, olive, ink. Alternate warm and cool; never the same twice in a row.
    `stamp_on`: a word from `say`; the stamp lands when that word is spoken.
    Labels: 1-3 words, uppercase. `label_size` shrinks long ones; `|` breaks a line.
    
    ### 5. Build, look, fix, render
    
    ```
    <python> <skill>/engine/papercut.py make story.json            # images, voice, animation, sound
    ```
    
    1. Open `pieces.jpg`: every cut-out on one sheet. A blob or a missing face means re-crop, raise `contrast`, or switch to `cut: false`.
    2. `<python> <skill>/engine/papercut.py snap story.json 2.1,5.4,...` with a time near the end of each beat (beat times are in `project/assets/audio/timing.json`). Check every frame: nothing off the edge, nothing important under the captions, every stamp readable.
    3. Fix story.json and run `make` again (cut-outs and voice are cached, only what changed is rebuilt).
    4. `<python> <skill>/engine/papercut.py make story.json --render` writes `<slug>.mp4` (1080x1920, 30 fps, -14 LUFS).
    
    Credits for every photo are written to `CREDITS.txt`. Put them in the video description.
    
    ## What the engine does for you
    
    - Halftone cut-outs with a scissor-cut paper border, a misregistered colour offset and a real cast shadow.
    - Stop-motion on twos: every move is quantised to 12 poses a second, every piece "boils" a pixel or two, the camera pushes in slowly in steps.
    - Paper wipes between scenes (slide, drop, cut), torn label strips, rubber stamps, marker circles, red string, typewriter documents with redaction bars.
    - Foley for every entrance (paper slide, tap, stamp thud, tape, typewriter) and a quiet tension bed, mixed under the voice to -14 LUFS.
    - Captions from the script's own words, timed by whisper, current word highlighted.
    
    ## Voices
    
    Kokoro voices that work for documentary: `af_heart` (default, best quality), `af_bella`, `bf_emma`, `am_michael`, `bm_george`. English only. For another language, record your own voice as `vo.wav` per beat or swap in a TTS that speaks it.
    
  • README.md4.0 KBa2fb3d0604ecupstream ↗
    # papercut
    
    **Vox-style paper-collage documentaries, made by Claude, for free, on your own computer.**
    
    One topic in, one finished vertical short out: halftone cut-outs, torn paper, rubber stamps, maps, typewriter documents, stop-motion on twos, paper sound effects, captions and a voice. No video model, no image model, no credits.
    
    ![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.
    
  • .gitattributes17 Bdfdb8b771ce0upstream ↗
    *.sh text eol=lf
    
  • .gitignore171 B1ff78fd8c917upstream ↗
    .venv/
    _test/
    __pycache__/
    engine/data/*.js
    engine/data/*.json
    **/project/
    **/sources/
    **/snaps*/
    **/*.mp4
    **/pieces.jpg
    **/sheet.png
    **/sheet.png.json
    engine/CREDITS.txt
    
  • CREDITS.md1.3 KB0ddc959608adupstream ↗
    # Credits and licences
    
    papercut's own code is MIT (see LICENSE). It stands on these:
    
    | Part | Project | Licence |
    |---|---|---|
    | Animation and rendering | [HyperFrames](https://github.com/heygen-com/hyperframes) | Apache-2.0 |
    | Animation timeline | [GSAP](https://gsap.com) (downloaded by setup) | GSAP Standard License, free |
    | Voice | [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [kokoro-onnx](https://github.com/thewh1teagle/kokoro-onnx) | Apache-2.0 / MIT |
    | Word timings | [faster-whisper](https://github.com/SYSTRAN/faster-whisper) | MIT |
    | Background removal | [rembg](https://github.com/danielgatis/rembg) with the IS-Net model | MIT / Apache-2.0 |
    | Maps | [Natural Earth](https://www.naturalearthdata.com) via [world-atlas](https://github.com/topojson/world-atlas), [d3-geo](https://github.com/d3/d3-geo), [topojson-client](https://github.com/topojson/topojson-client) | public domain / ISC |
    | Photos | [Wikimedia Commons](https://commons.wikimedia.org), public domain and CC0 only by default | per photo, written to each film's CREDITS.txt |
    | Fonts | Anton, Archivo Black (SIL OFL 1.1); Special Elite, Permanent Marker (Apache-2.0) | licence files in `engine/fonts` |
    
    Paper textures, scraps, tape, grain, every sound effect and the music bed are generated by papercut's code from a seed.
    
  • docs/demo-hopper.gif3.4 MB220e202c4840upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • docs/demo-map.gif3.6 MB9459b38ec710upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • docs/frames.jpg345 KBae333e30e32bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • engine/build.py43 KB7376eb336d1bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • engine/cutout.py6.2 KB8b604b170e3eupstream ↗
    """Turn any photo into a hand-cut halftone paper piece.
    
      photo -> (optional) background removal -> contrast -> halftone dots -> scissor-cut paper border
            -> offset accent silhouette (misregistered print) -> baked soft shadow  -> RGBA PNG
    
    Background removal is rembg (MIT) with the u2net / isnet models (Apache-2.0), on the CPU.
    """
    import math
    
    import cv2
    import numpy as np
    from PIL import Image, ImageDraw, ImageFilter, ImageOps
    
    from paper import INK, PAPER, CARDS, card, with_shadow
    
    _SESSION = None
    
    
    def _mask_rembg(img):
        global _SESSION
        from rembg import new_session, remove
        if _SESSION is None:
            _SESSION = new_session("isnet-general-use")
        m = remove(img, session=_SESSION, only_mask=True)
        m = np.asarray(m.convert("L"))
        m = (m > 110).astype(np.uint8)
        # keep the big parts only, close small gaps, fill pinholes
        n, lab, stats, _ = cv2.connectedComponentsWithStats(m, 8)
        if n > 1:
            big = stats[1:, cv2.CC_STAT_AREA].max()
            keep = [i for i in range(1, n) if stats[i, cv2.CC_STAT_AREA] > 0.08 * big]
            m = np.isin(lab, keep).astype(np.uint8)
        k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
        m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k)
        cnts, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
        filled = np.zeros_like(m)
        cv2.drawContours(filled, cnts, -1, 1, -1)
        return filled * 255
    
    
    def halftone(gray, mask, cell=8, angle=45, sup=3):
        """Ink dots on paper. gray: float32 0..1 (1 = white). Returns an L image, 0 = ink."""
        h, w = gray.shape
        W, H = w * sup, h * sup
        out = Image.new("L", (W, H), 255)
        d = ImageDraw.Draw(out)
        a = math.radians(angle)
        ca, sa = math.cos(a), math.sin(a)
        c = cell * sup
        diag = int(math.hypot(W, H)) + c
        cx, cy = W / 2, H / 2
        for v in range(-diag // 2, diag // 2, c):
            for u in range(-diag // 2, diag // 2, c):
                x = cx + u * ca - v * sa
                y = cy + u * sa + v * ca
                if 0 <= x < W and 0 <= y < H:
                    px, py = int(x / sup), int(y / sup)
                    if mask[py, px] < 128:
                        continue
                    dark = 1 - gray[py, px]
                    r = c * 0.71 * math.sqrt(max(0.0, dark))
                    if r > 0.5:
                        d.ellipse([x - r, y - r, x + r, y + r], fill=0)
        return out.resize((w, h), Image.LANCZOS)
    
    
    def _scissor_poly(mask, grow, seed, eps=5.0):
        """Grow the silhouette into a paper border and cut it with straight scissor strokes."""
        rng = np.random.default_rng(seed)
        k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (grow * 2 + 1, grow * 2 + 1))
        big = cv2.dilate((mask > 127).astype(np.uint8), k)
        cnts, _ = cv2.findContours(big, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
        out = np.zeros_like(big)
        for c in cnts:
            if cv2.contourArea(c) < 400:
                continue
            p = cv2.approxPolyDP(c, eps, True).reshape(-1, 2).astype(np.float32)
            p += rng.normal(0, 1.2, p.shape)  # a hand never cuts perfectly
            cv2.fillPoly(out, [p.astype(np.int32)], 1)
        return out * 255
    
    
    def cut_piece(src, size=900, cut=True, accent="red", border=14, seed=1, contrast=1.1, cell=None,
                  offset=(16, 12), paper=PAPER, dots=True):
        """Return an RGBA PIL image of the finished paper piece (shadow included)."""
        img = Image.open(src).convert("RGB")
        img = ImageOps.exif_transpose(img)
        s = size / max(img.size)
        img = img.resize((max(1, int(img.width * s)), max(1, int(img.height * s))), Image.LANCZOS)
        w, h = img.size
    
        if cut:
            m = _mask_rembg(img)
        else:
            m = np.full((h, w), 255, np.uint8)
    
        # tone: autocontrast inside the subject, a little local contrast, then a gentle S-curve
        g = ImageOps.grayscale(img)
        g = ImageOps.autocontrast(g, cutoff=2, mask=Image.fromarray(m))
        g = g.filter(ImageFilter.UnsharpMask(radius=6, percent=60, threshold=2))
        ga = np.asarray(g, np.float32) / 255
        ga = np.clip(0.5 + (ga - 0.5) * contrast, 0, 1)
        ga = ga ** 0.72                      # lift the mids so faces keep detail
        ga = 0.10 + ga * 0.90                # deepest shadow = 90% ink, never a solid black blob
    
        cell = cell or max(6, int(round(size / 115)))
        if dots:
            ht = np.asarray(halftone(ga, m, cell=cell), np.float32) / 255  # 1 paper, 0 ink
        else:
            # clean print for documents and handwriting: keep the detail, add a fine print grain
            rng = np.random.default_rng(seed)
            ht = np.clip(ga + rng.normal(0, 0.035, ga.shape), 0, 1).astype(np.float32)
    
        # canvas with room for border + accent offset
        pad = border + max(abs(offset[0]), abs(offset[1])) + 8
        W, H = w + pad * 2, h + pad * 2
        M = np.zeros((H, W), np.uint8)
        M[pad:pad + h, pad:pad + w] = m
    
        if cut:
            bmask = _scissor_poly(M, border, seed)
        else:
            rng = np.random.default_rng(seed)
            j = lambda: rng.normal(0, 2.5)
            q = [(pad - border + j(), pad - border + j()), (pad + w + border + j(), pad - border + j()),
                 (pad + w + border + j(), pad + h + border + j()), (pad - border + j(), pad + h + border + j())]
            im = Image.new("L", (W, H), 0)
            ImageDraw.Draw(im).polygon(q, fill=255)
            bmask = np.asarray(im)
    
        paper_tex = np.asarray(card(paper, W, H, seed + 3), np.float32)
        rgb = paper_tex.copy()
        ink = np.array(INK, np.float32)
        inner = np.zeros((H, W), np.float32)
        inner[pad:pad + h, pad:pad + w] = (m > 127) * (1 - ht)
        rgb = rgb * (1 - inner[..., None]) + ink[None, None, :] * inner[..., None]
    
        layers = []
        if accent:
            acol = np.array(CARDS.get(accent, accent), np.float32)
            am = np.roll(np.roll(bmask, offset[1], 0), offset[0], 1)
            acc = Image.new("RGBA", (W, H), tuple(int(v) for v in acol) + (0,))
            acc.putalpha(Image.fromarray(am))
            layers.append(acc)
        piece = Image.fromarray(np.clip(rgb, 0, 255).astype(np.uint8)).convert("RGBA")
        piece.putalpha(Image.fromarray(bmask))
        layers.append(piece)
        comp = Image.new("RGBA", (W, H), (0, 0, 0, 0))
        for L in layers:
            comp = Image.alpha_composite(comp, L)
        bbox = comp.getbbox()
        comp = comp.crop(bbox)
        return with_shadow(comp, pad=26)
    
    
    if __name__ == "__main__":
        import sys
        cut_piece(sys.argv[1], cut="--rect" not in sys.argv).save(sys.argv[2])
    
  • engine/fetch.py5.2 KBc231fee35e27upstream ↗
    """Find real archive photos you are allowed to use: Wikimedia Commons, public domain and CC0 only by default.
    
      search "Harvard Mark II"            -> numbered candidates + a contact sheet PNG to look at
      get "File:Harvard_Mark_II.jpg" out  -> downloads it and appends the credit line to CREDITS.txt
    
    CC BY / CC BY-SA images are allowed with --allow-by; their author + licence go into the credits and must be shown.
    """
    import json
    import os
    import re
    import sys
    import time
    
    import requests
    from PIL import Image, ImageDraw, ImageFont
    
    API = "https://commons.wikimedia.org/w/api.php"
    UA = {"User-Agent": "PapercutVideoEngine/1.0 (free paper-collage video engine)"}
    FREE = re.compile(r"public domain|^pd|cc0|no restrictions", re.I)
    BY = re.compile(r"^cc[ -]by", re.I)
    
    
    def _get(params, tries=4):
        for i in range(tries):
            r = requests.get(API, params=params, headers=UA, timeout=30)
            if r.status_code == 429:
                time.sleep(2 + i * 3)
                continue
            r.raise_for_status()
            return r.json()
        r.raise_for_status()
    
    
    def _clean(html):
        return re.sub(r"<[^>]+>", "", html or "").strip()
    
    
    def search(query, limit=24, allow_by=False):
        data = _get({
            "action": "query", "format": "json", "generator": "search", "gsrnamespace": 6,
            "gsrsearch": f"{query} filetype:bitmap", "gsrlimit": limit,
            "prop": "imageinfo", "iiprop": "url|size|mime|extmetadata", "iiurlwidth": 1600,
        })
        out = []
        for p in sorted(data.get("query", {}).get("pages", {}).values(), key=lambda p: p.get("index", 0)):
            ii = (p.get("imageinfo") or [{}])[0]
            md = ii.get("extmetadata", {})
            lic = _clean(md.get("LicenseShortName", {}).get("value"))
            ok = bool(FREE.search(lic)) or (allow_by and bool(BY.search(lic)))
            if not ok or ii.get("width", 0) < 500:
                continue
            out.append({
                "title": p["title"], "license": lic, "w": ii.get("width"), "h": ii.get("height"),
                "thumb": ii.get("thumburl") or ii.get("url"), "page": ii.get("descriptionurl"),
                "artist": _clean(md.get("Artist", {}).get("value"))[:80],
            })
        return out
    
    
    def contact_sheet(cands, path, cols=4, tw=300):
        font = ImageFont.load_default()
        rows = (len(cands) + cols - 1) // cols
        sheet = Image.new("RGB", (cols * tw, rows * (tw + 40)), (30, 30, 30))
        for i, c in enumerate(cands):
            try:
                r = requests.get(c["thumb"].replace("/1600px-", "/400px-"), headers=UA, timeout=30)
                from io import BytesIO
                im = Image.open(BytesIO(r.content)).convert("RGB")
                im.thumbnail((tw - 8, tw - 8))
            except Exception:
                continue
            x, y = (i % cols) * tw, (i // cols) * (tw + 40)
            sheet.paste(im, (x + 4, y + 4))
            d = ImageDraw.Draw(sheet)
            d.text((x + 6, y + tw + 2), f"{i + 1}. {c['title'][5:40]}", fill=(255, 255, 255), font=font)
            d.text((x + 6, y + tw + 18), f"{c['license'][:30]}  {c['w']}x{c['h']}", fill=(180, 180, 180), font=font)
        sheet.save(path)
    
    
    def get(title, out, credits="CREDITS.txt", allow_by=False):
        data = _get({"action": "query", "format": "json", "titles": title, "prop": "imageinfo",
                     "iiprop": "url|extmetadata", "iiurlwidth": 1280})  # 1280 is a standard Wikimedia thumb step
        page = next(iter(data["query"]["pages"].values()))
        ii = page["imageinfo"][0]
        md = ii.get("extmetadata", {})
        lic = _clean(md.get("LicenseShortName", {}).get("value"))
        if not (FREE.search(lic) or (allow_by and BY.search(lic))):
            raise SystemExit(f"refused: {title} is '{lic}', not public domain/CC0 (use --allow-by for CC BY)")
        url = (ii.get("thumburl") or ii["url"]).split("?")[0]  # tracking params get rate-limited
        os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
        # curl, not requests: Wikimedia's edge rate-limits python's HTTP fingerprint on upload.wikimedia.org
        import subprocess
        for i in range(5):
            code = subprocess.run(["curl", "-s", "-L", "-o", out, "-w", "%{http_code}", "-A", UA["User-Agent"], url],
                                  capture_output=True, text=True).stdout.strip()
            if code == "200":
                break
            time.sleep(5 * (i + 1))
        if code != "200":
            raise SystemExit(f"download failed ({code}): {url}")
        time.sleep(1.0)
        artist = _clean(md.get("Artist", {}).get("value"))[:120]
        line = f"{os.path.basename(out)}: {title} | {artist or 'unknown'} | {lic} | {ii.get('descriptionurl')}\n"
        existing = open(credits, encoding="utf-8").read() if os.path.exists(credits) else ""
        if line not in existing:
            open(credits, "a", encoding="utf-8").write(line)
        return out
    
    
    if __name__ == "__main__":
        sys.stdout.reconfigure(encoding="utf-8")
        cmd = sys.argv[1]
        allow = "--allow-by" in sys.argv
        args = [a for a in sys.argv[2:] if not a.startswith("--")]
        if cmd == "search":
            c = search(args[0], allow_by=allow)
            for i, x in enumerate(c, 1):
                print(f"{i:2d}. {x['title']}  [{x['license']}]  {x['w']}x{x['h']}")
            if len(args) > 1:
                contact_sheet(c, args[1])
                json.dump(c, open(args[1] + ".json", "w", encoding="utf-8"), indent=1)
                print("sheet:", args[1])
        elif cmd == "get":
            print(get(args[0], args[1], allow_by=allow))
    
  • engine/fonts/Anton-Regular.ttf167 KB4d65707db9d8upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • engine/fonts/ArchivoBlack-Regular.ttf89 KB82d07de3b84eupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • engine/fonts/LICENSE-PermanentMarker.txt11 KBd64569567334upstream ↗
    
                                     Apache License
                               Version 2.0, January 2004
                            http://www.apache.org/licenses/
    
       TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    
       1. Definitions.
    
          "License" shall mean the terms and conditions for use, reproduction,
          and distribution as defined by Sections 1 through 9 of this document.
    
          "Licensor" shall mean the copyright owner or entity authorized by
          the copyright owner that is granting the License.
    
          "Legal Entity" shall mean the union of the acting entity and all
          other entities that control, are controlled by, or are under common
          control with that entity. For the purposes of this definition,
          "control" means (i) the power, direct or indirect, to cause the
          direction or management of such entity, whether by contract or
          otherwise, or (ii) ownership of fifty percent (50%) or more of the
          outstanding shares, or (iii) beneficial ownership of such entity.
    
          "You" (or "Your") shall mean an individual or Legal Entity
          exercising permissions granted by this License.
    
          "Source" form shall mean the preferred form for making modifications,
          including but not limited to software source code, documentation
          source, and configuration files.
    
          "Object" form shall mean any form resulting from mechanical
          transformation or translation of a Source form, including but
          not limited to compiled object code, generated documentation,
          and conversions to other media types.
    
          "Work" shall mean the work of authorship, whether in Source or
          Object form, made available under the License, as indicated by a
          copyright notice that is included in or attached to the work
          (an example is provided in the Appendix below).
    
          "Derivative Works" shall mean any work, whether in Source or Object
          form, that is based on (or derived from) the Work and for which the
          editorial revisions, annotations, elaborations, or other modifications
          represent, as a whole, an original work of authorship. For the purposes
          of this License, Derivative Works shall not include works that remain
          separable from, or merely link (or bind by name) to the interfaces of,
          the Work and Derivative Works thereof.
    
          "Contribution" shall mean any work of authorship, including
          the original version of the Work and any modifications or additions
          to that Work or Derivative Works thereof, that is intentionally
          submitted to Licensor for inclusion in the Work by the copyright owner
          or by an individual or Legal Entity authorized to submit on behalf of
          the copyright owner. For the purposes of this definition, "submitted"
          means any form of electronic, verbal, or written communication sent
          to the Licensor or its representatives, including but not limited to
          communication on electronic mailing lists, source code control systems,
          and issue tracking systems that are managed by, or on behalf of, the
          Licensor for the purpose of discussing and improving the Work, but
          excluding communication that is conspicuously marked or otherwise
          designated in writing by the copyright owner as "Not a Contribution."
    
          "Contributor" shall mean Licensor and any individual or Legal Entity
          on behalf of whom a Contribution has been received by Licensor and
          subsequently incorporated within the Work.
    
       2. Grant of Copyright License. Subject to the terms and conditions of
          this License, each Contributor hereby grants to You a perpetual,
          worldwide, non-exclusive, no-charge, royalty-free, irrevocable
          copyright license to reproduce, prepare Derivative Works of,
          publicly display, publicly perform, sublicense, and distribute the
          Work and such Derivative Works in Source or Object form.
    
       3. Grant of Patent License. Subject to the terms and conditions of
          this License, each Contributor hereby grants to You a perpetual,
          worldwide, non-exclusive, no-charge, royalty-free, irrevocable
          (except as stated in this section) patent license to make, have made,
          use, offer to sell, sell, import, and otherwise transfer the Work,
          where such license applies only to those patent claims licensable
          by such Contributor that are necessarily infringed by their
          Contribution(s) alone or by combination of their Contribution(s)
          with the Work to which such Contribution(s) was submitted. If You
          institute patent litigation against any entity (including a
          cross-claim or counterclaim in a lawsuit) alleging that the Work
          or a Contribution incorporated within the Work constitutes direct
          or contributory patent infringement, then any patent licenses
          granted to You under this License for that Work shall terminate
          as of the date such litigation is filed.
    
       4. Redistribution. You may reproduce and distribute copies of the
          Work or Derivative Works thereof in any medium, with or without
          modifications, and in Source or Object form, provided that You
          meet the following conditions:
    
          (a) You must give any other recipients of the Work or
              Derivative Works a copy of this License; and
    
          (b) You must cause any modified files to carry prominent notices
              stating that You changed the files; and
    
          (c) You must retain, in the Source form of any Derivative Works
              that You distribute, all copyright, patent, trademark, and
              attribution notices from the Source form of the Work,
              excluding those notices that do not pertain to any part of
              the Derivative Works; and
    
          (d) If the Work includes a "NOTICE" text file as part of its
              distribution, then any Derivative Works that You distribute must
              include a readable copy of the attribution notices contained
              within such NOTICE file, excluding those notices that do not
              pertain to any part of the Derivative Works, in at least one
              of the following places: within a NOTICE text file distributed
              as part of the Derivative Works; within the Source form or
              documentation, if provided along with the Derivative Works; or,
              within a display generated by the Derivative Works, if and
              wherever such third-party notices normally appear. The contents
              of the NOTICE file are for informational purposes only and
              do not modify the License. You may add Your own attribution
              notices within Derivative Works that You distribute, alongside
              or as an addendum to the NOTICE text from the Work, provided
              that such additional attribution notices cannot be construed
              as modifying the License.
    
          You may add Your own copyright statement to Your modifications and
          may provide additional or different license terms and conditions
          for use, reproduction, or distribution of Your modifications, or
          for any such Derivative Works as a whole, provided Your use,
          reproduction, and distribution of the Work otherwise complies with
          the conditions stated in this License.
    
       5. Submission of Contributions. Unless You explicitly state otherwise,
          any Contribution intentionally submitted for inclusion in the Work
          by You to the Licensor shall be under the terms and conditions of
          this License, without any additional terms or conditions.
          Notwithstanding the above, nothing herein shall supersede or modify
          the terms of any separate license agreement you may have executed
          with Licensor regarding such Contributions.
    
       6. Trademarks. This License does not grant permission to use the trade
          names, trademarks, service marks, or product names of the Licensor,
          except as required for reasonable and customary use in describing the
          origin of the Work and reproducing the content of the NOTICE file.
    
       7. Disclaimer of Warranty. Unless required by applicable law or
          agreed to in writing, Licensor provides the Work (and each
          Contributor provides its Contributions) on an "AS IS" BASIS,
          WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
          implied, including, without limitation, any warranties or conditions
          of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
          PARTICULAR PURPOSE. You are solely responsible for determining the
          appropriateness of using or redistributing the Work and assume any
          risks associated with Your exercise of permissions under this License.
    
       8. Limitation of Liability. In no event and under no legal theory,
          whether in tort (including negligence), contract, or otherwise,
          unless required by applicable law (such as deliberate and grossly
          negligent acts) or agreed to in writing, shall any Contributor be
          liable to You for damages, including any direct, indirect, special,
          incidental, or consequential damages of any character arising as a
          result of this License or out of the use or inability to use the
          Work (including but not limited to damages for loss of goodwill,
          work stoppage, computer failure or malfunction, or any and all
          other commercial damages or losses), even if such Contributor
          has been advised of the possibility of such damages.
    
       9. Accepting Warranty or Additional Liability. While redistributing
          the Work or Derivative Works thereof, You may choose to offer,
          and charge a fee for, acceptance of support, warranty, indemnity,
          or other liability obligations and/or rights consistent with this
          License. However, in accepting such obligations, You may act only
          on Your own behalf and on Your sole responsibility, not on behalf
          of any other Contributor, and only if You agree to indemnify,
          defend, and hold each Contributor harmless for any liability
          incurred by, or claims asserted against, such Contributor by reason
          of your accepting any such warranty or additional liability.
    
       END OF TERMS AND CONDITIONS
    
       APPENDIX: How to apply the Apache License to your work.
    
          To apply the Apache License to your work, attach the following
          boilerplate notice, with the fields enclosed by brackets "[]"
          replaced with your own identifying information. (Don't include
          the brackets!)  The text should be enclosed in the appropriate
          comment syntax for the file format. We also recommend that a
          file or class name and description of purpose be included on the
          same "printed page" as the copyright notice for easier
          identification within third-party archives.
    
       Copyright [yyyy] [name of copyright owner]
    
       Licensed under the Apache License, Version 2.0 (the "License");
       you may not use this file except in compliance with the License.
       You may obtain a copy of the License at
    
           http://www.apache.org/licenses/LICENSE-2.0
    
       Unless required by applicable law or agreed to in writing, software
       distributed under the License is distributed on an "AS IS" BASIS,
       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
       See the License for the specific language governing permissions and
       limitations under the License.
    
  • engine/fonts/LICENSE-SpecialElite.txt11 KBd64569567334upstream ↗
    
                                     Apache License
                               Version 2.0, January 2004
                            http://www.apache.org/licenses/
    
       TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    
       1. Definitions.
    
          "License" shall mean the terms and conditions for use, reproduction,
          and distribution as defined by Sections 1 through 9 of this document.
    
          "Licensor" shall mean the copyright owner or entity authorized by
          the copyright owner that is granting the License.
    
          "Legal Entity" shall mean the union of the acting entity and all
          other entities that control, are controlled by, or are under common
          control with that entity. For the purposes of this definition,
          "control" means (i) the power, direct or indirect, to cause the
          direction or management of such entity, whether by contract or
          otherwise, or (ii) ownership of fifty percent (50%) or more of the
          outstanding shares, or (iii) beneficial ownership of such entity.
    
          "You" (or "Your") shall mean an individual or Legal Entity
          exercising permissions granted by this License.
    
          "Source" form shall mean the preferred form for making modifications,
          including but not limited to software source code, documentation
          source, and configuration files.
    
          "Object" form shall mean any form resulting from mechanical
          transformation or translation of a Source form, including but
          not limited to compiled object code, generated documentation,
          and conversions to other media types.
    
          "Work" shall mean the work of authorship, whether in Source or
          Object form, made available under the License, as indicated by a
          copyright notice that is included in or attached to the work
          (an example is provided in the Appendix below).
    
          "Derivative Works" shall mean any work, whether in Source or Object
          form, that is based on (or derived from) the Work and for which the
          editorial revisions, annotations, elaborations, or other modifications
          represent, as a whole, an original work of authorship. For the purposes
          of this License, Derivative Works shall not include works that remain
          separable from, or merely link (or bind by name) to the interfaces of,
          the Work and Derivative Works thereof.
    
          "Contribution" shall mean any work of authorship, including
          the original version of the Work and any modifications or additions
          to that Work or Derivative Works thereof, that is intentionally
          submitted to Licensor for inclusion in the Work by the copyright owner
          or by an individual or Legal Entity authorized to submit on behalf of
          the copyright owner. For the purposes of this definition, "submitted"
          means any form of electronic, verbal, or written communication sent
          to the Licensor or its representatives, including but not limited to
          communication on electronic mailing lists, source code control systems,
          and issue tracking systems that are managed by, or on behalf of, the
          Licensor for the purpose of discussing and improving the Work, but
          excluding communication that is conspicuously marked or otherwise
          designated in writing by the copyright owner as "Not a Contribution."
    
          "Contributor" shall mean Licensor and any individual or Legal Entity
          on behalf of whom a Contribution has been received by Licensor and
          subsequently incorporated within the Work.
    
       2. Grant of Copyright License. Subject to the terms and conditions of
          this License, each Contributor hereby grants to You a perpetual,
          worldwide, non-exclusive, no-charge, royalty-free, irrevocable
          copyright license to reproduce, prepare Derivative Works of,
          publicly display, publicly perform, sublicense, and distribute the
          Work and such Derivative Works in Source or Object form.
    
       3. Grant of Patent License. Subject to the terms and conditions of
          this License, each Contributor hereby grants to You a perpetual,
          worldwide, non-exclusive, no-charge, royalty-free, irrevocable
          (except as stated in this section) patent license to make, have made,
          use, offer to sell, sell, import, and otherwise transfer the Work,
          where such license applies only to those patent claims licensable
          by such Contributor that are necessarily infringed by their
          Contribution(s) alone or by combination of their Contribution(s)
          with the Work to which such Contribution(s) was submitted. If You
          institute patent litigation against any entity (including a
          cross-claim or counterclaim in a lawsuit) alleging that the Work
          or a Contribution incorporated within the Work constitutes direct
          or contributory patent infringement, then any patent licenses
          granted to You under this License for that Work shall terminate
          as of the date such litigation is filed.
    
       4. Redistribution. You may reproduce and distribute copies of the
          Work or Derivative Works thereof in any medium, with or without
          modifications, and in Source or Object form, provided that You
          meet the following conditions:
    
          (a) You must give any other recipients of the Work or
              Derivative Works a copy of this License; and
    
          (b) You must cause any modified files to carry prominent notices
              stating that You changed the files; and
    
          (c) You must retain, in the Source form of any Derivative Works
              that You distribute, all copyright, patent, trademark, and
              attribution notices from the Source form of the Work,
              excluding those notices that do not pertain to any part of
              the Derivative Works; and
    
          (d) If the Work includes a "NOTICE" text file as part of its
              distribution, then any Derivative Works that You distribute must
              include a readable copy of the attribution notices contained
              within such NOTICE file, excluding those notices that do not
              pertain to any part of the Derivative Works, in at least one
              of the following places: within a NOTICE text file distributed
              as part of the Derivative Works; within the Source form or
              documentation, if provided along with the Derivative Works; or,
              within a display generated by the Derivative Works, if and
              wherever such third-party notices normally appear. The contents
              of the NOTICE file are for informational purposes only and
              do not modify the License. You may add Your own attribution
              notices within Derivative Works that You distribute, alongside
              or as an addendum to the NOTICE text from the Work, provided
              that such additional attribution notices cannot be construed
              as modifying the License.
    
          You may add Your own copyright statement to Your modifications and
          may provide additional or different license terms and conditions
          for use, reproduction, or distribution of Your modifications, or
          for any such Derivative Works as a whole, provided Your use,
          reproduction, and distribution of the Work otherwise complies with
          the conditions stated in this License.
    
       5. Submission of Contributions. Unless You explicitly state otherwise,
          any Contribution intentionally submitted for inclusion in the Work
          by You to the Licensor shall be under the terms and conditions of
          this License, without any additional terms or conditions.
          Notwithstanding the above, nothing herein shall supersede or modify
          the terms of any separate license agreement you may have executed
          with Licensor regarding such Contributions.
    
       6. Trademarks. This License does not grant permission to use the trade
          names, trademarks, service marks, or product names of the Licensor,
          except as required for reasonable and customary use in describing the
          origin of the Work and reproducing the content of the NOTICE file.
    
       7. Disclaimer of Warranty. Unless required by applicable law or
          agreed to in writing, Licensor provides the Work (and each
          Contributor provides its Contributions) on an "AS IS" BASIS,
          WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
          implied, including, without limitation, any warranties or conditions
          of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
          PARTICULAR PURPOSE. You are solely responsible for determining the
          appropriateness of using or redistributing the Work and assume any
          risks associated with Your exercise of permissions under this License.
    
       8. Limitation of Liability. In no event and under no legal theory,
          whether in tort (including negligence), contract, or otherwise,
          unless required by applicable law (such as deliberate and grossly
          negligent acts) or agreed to in writing, shall any Contributor be
          liable to You for damages, including any direct, indirect, special,
          incidental, or consequential damages of any character arising as a
          result of this License or out of the use or inability to use the
          Work (including but not limited to damages for loss of goodwill,
          work stoppage, computer failure or malfunction, or any and all
          other commercial damages or losses), even if such Contributor
          has been advised of the possibility of such damages.
    
       9. Accepting Warranty or Additional Liability. While redistributing
          the Work or Derivative Works thereof, You may choose to offer,
          and charge a fee for, acceptance of support, warranty, indemnity,
          or other liability obligations and/or rights consistent with this
          License. However, in accepting such obligations, You may act only
          on Your own behalf and on Your sole responsibility, not on behalf
          of any other Contributor, and only if You agree to indemnify,
          defend, and hold each Contributor harmless for any liability
          incurred by, or claims asserted against, such Contributor by reason
          of your accepting any such warranty or additional liability.
    
       END OF TERMS AND CONDITIONS
    
       APPENDIX: How to apply the Apache License to your work.
    
          To apply the Apache License to your work, attach the following
          boilerplate notice, with the fields enclosed by brackets "[]"
          replaced with your own identifying information. (Don't include
          the brackets!)  The text should be enclosed in the appropriate
          comment syntax for the file format. We also recommend that a
          file or class name and description of purpose be included on the
          same "printed page" as the copyright notice for easier
          identification within third-party archives.
    
       Copyright [yyyy] [name of copyright owner]
    
       Licensed under the Apache License, Version 2.0 (the "License");
       you may not use this file except in compliance with the License.
       You may obtain a copy of the License at
    
           http://www.apache.org/licenses/LICENSE-2.0
    
       Unless required by applicable law or agreed to in writing, software
       distributed under the License is distributed on an "AS IS" BASIS,
       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
       See the License for the specific language governing permissions and
       limitations under the License.
    
  • engine/fonts/OFL-Anton.txt4.3 KBefd0dd80c2deupstream ↗
    Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git)
    
    This Font Software is licensed under the SIL Open Font License, Version 1.1.
    This license is copied below, and is also available with a FAQ at:
    http://scripts.sil.org/OFL
    
    
    -----------------------------------------------------------
    SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
    -----------------------------------------------------------
    
    PREAMBLE
    The goals of the Open Font License (OFL) are to stimulate worldwide
    development of collaborative font projects, to support the font creation
    efforts of academic and linguistic communities, and to provide a free and
    open framework in which fonts may be shared and improved in partnership
    with others.
    
    The OFL allows the licensed fonts to be used, studied, modified and
    redistributed freely as long as they are not sold by themselves. The
    fonts, including any derivative works, can be bundled, embedded, 
    redistributed and/or sold with any software provided that any reserved
    names are not used by derivative works. The fonts and derivatives,
    however, cannot be released under any other type of license. The
    requirement for fonts to remain under this license does not apply
    to any document created using the fonts or their derivatives.
    
    DEFINITIONS
    "Font Software" refers to the set of files released by the Copyright
    Holder(s) under this license and clearly marked as such. This may
    include source files, build scripts and documentation.
    
    "Reserved Font Name" refers to any names specified as such after the
    copyright statement(s).
    
    "Original Version" refers to the collection of Font Software components as
    distributed by the Copyright Holder(s).
    
    "Modified Version" refers to any derivative made by adding to, deleting,
    or substituting -- in part or in whole -- any of the components of the
    Original Version, by changing formats or by porting the Font Software to a
    new environment.
    
    "Author" refers to any designer, engineer, programmer, technical
    writer or other person who contributed to the Font Software.
    
    PERMISSION & CONDITIONS
    Permission is hereby granted, free of charge, to any person obtaining
    a copy of the Font Software, to use, study, copy, merge, embed, modify,
    redistribute, and sell modified and unmodified copies of the Font
    Software, subject to the following conditions:
    
    1) Neither the Font Software nor any of its individual components,
    in Original or Modified Versions, may be sold by itself.
    
    2) Original or Modified Versions of the Font Software may be bundled,
    redistributed and/or sold with any software, provided that each copy
    contains the above copyright notice and this license. These can be
    included either as stand-alone text files, human-readable headers or
    in the appropriate machine-readable metadata fields within text or
    binary files as long as those fields can be easily viewed by the user.
    
    3) No Modified Version of the Font Software may use the Reserved Font
    Name(s) unless explicit written permission is granted by the corresponding
    Copyright Holder. This restriction only applies to the primary font name as
    presented to the users.
    
    4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
    Software shall not be used to promote, endorse or advertise any
    Modified Version, except to acknowledge the contribution(s) of the
    Copyright Holder(s) and the Author(s) or with their explicit written
    permission.
    
    5) The Font Software, modified or unmodified, in part or in whole,
    must be distributed entirely under this license, and must not be
    distributed under any other license. The requirement for fonts to
    remain under this license does not apply to any document created
    using the Font Software.
    
    TERMINATION
    This license becomes null and void if any of the above conditions are
    not met.
    
    DISCLAIMER
    THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
    MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
    OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
    COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
    INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
    DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
    OTHER DEALINGS IN THE FONT SOFTWARE.
    
  • engine/fonts/OFL-ArchivoBlack.txt4.3 KB147454335649upstream ↗
    Copyright 2017 The Archivo Black Project Authors (https://github.com/Omnibus-Type/ArchivoBlack)
    
    This Font Software is licensed under the SIL Open Font License, Version 1.1.
    This license is copied below, and is also available with a FAQ at:
    http://scripts.sil.org/OFL
    
    
    -----------------------------------------------------------
    SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
    -----------------------------------------------------------
    
    PREAMBLE
    The goals of the Open Font License (OFL) are to stimulate worldwide
    development of collaborative font projects, to support the font creation
    efforts of academic and linguistic communities, and to provide a free and
    open framework in which fonts may be shared and improved in partnership
    with others.
    
    The OFL allows the licensed fonts to be used, studied, modified and
    redistributed freely as long as they are not sold by themselves. The
    fonts, including any derivative works, can be bundled, embedded, 
    redistributed and/or sold with any software provided that any reserved
    names are not used by derivative works. The fonts and derivatives,
    however, cannot be released under any other type of license. The
    requirement for fonts to remain under this license does not apply
    to any document created using the fonts or their derivatives.
    
    DEFINITIONS
    "Font Software" refers to the set of files released by the Copyright
    Holder(s) under this license and clearly marked as such. This may
    include source files, build scripts and documentation.
    
    "Reserved Font Name" refers to any names specified as such after the
    copyright statement(s).
    
    "Original Version" refers to the collection of Font Software components as
    distributed by the Copyright Holder(s).
    
    "Modified Version" refers to any derivative made by adding to, deleting,
    or substituting -- in part or in whole -- any of the components of the
    Original Version, by changing formats or by porting the Font Software to a
    new environment.
    
    "Author" refers to any designer, engineer, programmer, technical
    writer or other person who contributed to the Font Software.
    
    PERMISSION & CONDITIONS
    Permission is hereby granted, free of charge, to any person obtaining
    a copy of the Font Software, to use, study, copy, merge, embed, modify,
    redistribute, and sell modified and unmodified copies of the Font
    Software, subject to the following conditions:
    
    1) Neither the Font Software nor any of its individual components,
    in Original or Modified Versions, may be sold by itself.
    
    2) Original or Modified Versions of the Font Software may be bundled,
    redistributed and/or sold with any software, provided that each copy
    contains the above copyright notice and this license. These can be
    included either as stand-alone text files, human-readable headers or
    in the appropriate machine-readable metadata fields within text or
    binary files as long as those fields can be easily viewed by the user.
    
    3) No Modified Version of the Font Software may use the Reserved Font
    Name(s) unless explicit written permission is granted by the corresponding
    Copyright Holder. This restriction only applies to the primary font name as
    presented to the users.
    
    4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
    Software shall not be used to promote, endorse or advertise any
    Modified Version, except to acknowledge the contribution(s) of the
    Copyright Holder(s) and the Author(s) or with their explicit written
    permission.
    
    5) The Font Software, modified or unmodified, in part or in whole,
    must be distributed entirely under this license, and must not be
    distributed under any other license. The requirement for fonts to
    remain under this license does not apply to any document created
    using the Font Software.
    
    TERMINATION
    This license becomes null and void if any of the above conditions are
    not met.
    
    DISCLAIMER
    THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
    MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
    OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
    COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
    INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
    DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
    OTHER DEALINGS IN THE FONT SOFTWARE.
    
  • engine/fonts/PermanentMarker-Regular.ttf73 KB3218fc5b3d39upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • engine/fonts/SpecialElite-Regular.ttf162 KB6654876f95f1upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • engine/paper.py5.9 KBc852c13f056cupstream ↗
    """Procedural paper: every texture is generated from a seed, so the engine ships with no image files to license.
    
    Backgrounds are flat coloured card with fibres, stains and print grain. Scraps are blank newsprint with torn edges
    and faint halftone, never readable text (invented headlines are what make AI collage look fake).
    """
    import numpy as np
    from PIL import Image, ImageDraw, ImageFilter
    
    # Card colours. One hot signal colour, earthy neutrals, ink. Keys are what story.json uses for "bg".
    CARDS = {
        "red": (196, 48, 38),
        "mustard": (214, 165, 52),
        "teal": (38, 104, 104),
        "navy": (30, 42, 66),
        "newsprint": (226, 216, 194),
        "kraft": (176, 136, 92),
        "ink": (28, 26, 24),
        "cream": (238, 230, 212),
        "pink": (222, 150, 140),
        "olive": (120, 120, 72),
    }
    INK = (26, 23, 20)
    PAPER = (240, 233, 219)
    
    
    def _noise(h, w, rng, scales=(1, 4, 16, 64), weights=(0.35, 0.3, 0.2, 0.15)):
        """Multi-octave value noise in [0,1], built by upsampling random grids (fast, no extra deps)."""
        out = np.zeros((h, w), np.float32)
        for s, wt in zip(scales, weights):
            gh, gw = max(2, h // s), max(2, w // s)
            g = rng.random((gh, gw)).astype(np.float32)
            im = Image.fromarray((g * 255).astype(np.uint8)).resize((w, h), Image.BICUBIC)
            out += wt * (np.asarray(im, np.float32) / 255)
        return out / sum(weights)
    
    
    def card(color, w, h, seed=0):
        """A full-frame sheet of coloured card: fibres, soft stains, edge darkening, print grain."""
        rng = np.random.default_rng(seed)
        base = np.array(CARDS.get(color, color), np.float32)
        n = _noise(h, w, rng)
        fib = _noise(h, w, rng, scales=(1, 2), weights=(0.6, 0.4))
        stain = _noise(h, w, rng, scales=(128, 256), weights=(0.5, 0.5))
        yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
        vig = ((xx / w - 0.5) ** 2 + (yy / h - 0.5) ** 2) ** 0.5
        shade = 1 + 0.10 * (n - 0.5) + 0.05 * (fib - 0.5) - 0.10 * np.clip(stain - 0.55, 0, 1) - 0.22 * vig ** 2
        img = np.clip(base[None, None, :] * shade[..., None], 0, 255)
        # sparse fibres: short light and dark hairs
        im = Image.fromarray(img.astype(np.uint8))
        d = ImageDraw.Draw(im, "RGBA")
        for _ in range(int(w * h / 9000)):
            x, y = rng.random() * w, rng.random() * h
            a, ln = rng.random() * np.pi, 4 + rng.random() * 14
            c = (255, 255, 255, 28) if rng.random() < 0.5 else (0, 0, 0, 22)
            d.line([(x, y), (x + np.cos(a) * ln, y + np.sin(a) * ln)], fill=c, width=1)
        return im
    
    
    def torn_mask(w, h, seed=0, tear=10, edges="tblr"):
        """An RGBA-ready L mask for a rectangle whose chosen edges are torn (jagged, fibrous)."""
        rng = np.random.default_rng(seed)
        pts = []
    
        def edge(p0, p1, torn):
            n = max(2, int(np.hypot(p1[0] - p0[0], p1[1] - p0[1]) / 4))
            walk = 0.0
            for i in range(n):
                t = i / n
                x, y = p0[0] + (p1[0] - p0[0]) * t, p0[1] + (p1[1] - p0[1]) * t
                if torn:
                    # slow wander (the tear line) + fine fibre fuzz, pushed inwards only
                    walk = 0.85 * walk + rng.normal(0, 0.55)
                    k = np.clip(0.5 + walk * 0.25, 0, 1) * tear + rng.random() * tear * 0.25
                    dx, dy = p1[1] - p0[1], -(p1[0] - p0[0])
                    L = np.hypot(dx, dy) or 1
                    x, y = x + dx / L * k, y + dy / L * k
                pts.append((x, y))
    
        m = tear
        edge((m, m), (w - m, m), "t" in edges)
        edge((w - m, m), (w - m, h - m), "r" in edges)
        edge((w - m, h - m), (m, h - m), "b" in edges)
        edge((m, h - m), (m, m), "l" in edges)
        mask = Image.new("L", (w, h), 0)
        ImageDraw.Draw(mask).polygon(pts, fill=255)
        return mask
    
    
    def halftone_field(w, h, seed=0, cell=9, strength=0.22):
        """Faint, unreadable halftone grain: the 'printed newsprint' look without any letters."""
        rng = np.random.default_rng(seed)
        tone = _noise(h, w, rng, scales=(64, 160), weights=(0.6, 0.4))
        sup = 2
        W, H = w * sup, h * sup
        im = Image.new("L", (W, H), 0)
        d = ImageDraw.Draw(im)
        c = cell * sup
        for y in range(0, H, c):
            for x in range(0, W, c):
                ox = (c // 2) if (y // c) % 2 else 0
                v = tone[min(h - 1, y // sup), min(w - 1, (x + ox) // sup)]
                r = c * 0.5 * (v * strength * 2)
                if r > 0.6:
                    d.ellipse([x + ox - r, y - r, x + ox + r, y + r], fill=255)
        return np.asarray(im.resize((w, h), Image.LANCZOS), np.float32) / 255
    
    
    def scrap(w, h, seed=0, color="newsprint", dots=True):
        """A torn piece of blank newsprint/card with soft shadow baked in (RGBA, padded for the shadow)."""
        pad = 24
        body = card(color, w, h, seed).convert("RGBA")
        if dots:
            hf = halftone_field(w, h, seed + 7)
            arr = np.asarray(body, np.float32)
            arr[..., :3] *= (1 - 0.35 * hf[..., None])
            body = Image.fromarray(arr.astype(np.uint8), "RGBA")
        mask = torn_mask(w, h, seed, tear=9)
        body.putalpha(mask)
        return with_shadow(body, pad)
    
    
    def with_shadow(rgba, pad=24, off=(6, 10), blur=9, alpha=0.45):
        """Bake a soft cast shadow under an RGBA cut-out: paper lifted a few millimetres off the table."""
        w, h = rgba.size
        out = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
        a = rgba.split()[3]
        sh = Image.new("L", out.size, 0)
        sh.paste(a, (pad + off[0], pad + off[1]))
        sh = sh.filter(ImageFilter.GaussianBlur(blur)).point(lambda v: int(v * alpha))
        shadow = Image.new("RGBA", out.size, (20, 14, 8, 0))
        shadow.putalpha(sh)
        out = Image.alpha_composite(out, shadow)
        layer = Image.new("RGBA", out.size, (0, 0, 0, 0))
        layer.paste(rgba, (pad, pad))
        return Image.alpha_composite(out, layer)
    
    
    def tape(w=220, h=64, seed=0):
        """Masking tape: translucent cream with torn short ends."""
        rng = np.random.default_rng(seed)
        t = card((222, 208, 170), w, h, seed).convert("RGBA")
        m = torn_mask(w, h, seed, tear=6, edges="lr")
        m = m.point(lambda v: int(v * (0.78 + 0.0 * rng.random())))
        t.putalpha(m)
        return t
    
  • engine/papercut.py7.6 KB4a9e5b3ccc3cupstream ↗
    """papercut: one story.json in, one stop-motion paper-collage documentary out. Free, local, no credits.
    
      python papercut.py search "Apollo 13" sheet.png      find public-domain photos (prints + contact sheet)
      python papercut.py make story.json                   images -> cutouts -> voice -> timings -> animation -> sound
      python papercut.py make story.json --render          ...and render the MP4
      python papercut.py snap story.json 3.2,7.5           stills at those seconds, to check a frame before rendering
    """
    import hashlib
    import json
    import os
    import shutil
    import subprocess
    import sys
    
    import numpy as np
    import soundfile as sf
    from scipy.signal import resample_poly
    
    HERE = os.path.dirname(os.path.abspath(__file__))
    sys.path.insert(0, HERE)
    
    import build  # noqa: E402
    import fetch  # noqa: E402
    import sfx  # noqa: E402
    import voice  # noqa: E402
    from cutout import cut_piece  # noqa: E402
    
    NPX = shutil.which("npx") or "npx"
    
    
    def _h(o):
        return hashlib.sha1(json.dumps(o, sort_keys=True).encode()).hexdigest()[:10]
    
    
    def load(path):
        story = json.load(open(path, encoding="utf-8"))
        root = os.path.dirname(os.path.abspath(path))
        proj = os.path.join(root, story.get("project", "project"))
        return story, root, proj
    
    
    def images(story, root, proj):
        src_dir = os.path.join(root, "sources")
        out_dir = os.path.join(proj, "assets", "img")
        os.makedirs(src_dir, exist_ok=True)
        os.makedirs(out_dir, exist_ok=True)
        credits = os.path.join(root, "CREDITS.txt")
        done = {}
        for key, spec in story.get("images", {}).items():
            if isinstance(spec, str):
                spec = {"commons": spec}
            if "file" in spec:
                raw = os.path.join(root, spec["file"])
            else:
                ext = os.path.splitext(spec["commons"])[1].lower()
                ext = ".jpg" if ext in (".tif", ".tiff", ".webp", ".svg") else ext
                raw = os.path.join(src_dir, key + ext)
                if not os.path.exists(raw):
                    fetch.get(spec["commons"], raw, credits=credits, allow_by=spec.get("allow_by", False))
            params = {k: spec.get(k) for k in ("cut", "accent", "size", "contrast", "crop", "dots")}
            tag = _h([params, os.path.getsize(raw)])
            out = os.path.join(out_dir, f"{key}-{tag}.png")
            if not os.path.exists(out):
                src = raw
                if spec.get("crop"):  # [l, t, r, b] as fractions, to frame a face or an object before cutting
                    from PIL import Image
                    im = Image.open(raw).convert("RGB")
                    l, t, r, b = spec["crop"]
                    im = im.crop((int(l * im.width), int(t * im.height), int(r * im.width), int(b * im.height)))
                    src = os.path.join(src_dir, key + "-crop.png")
                    im.save(src)
                piece = cut_piece(src, size=spec.get("size", 1000), cut=spec.get("cut", True),
                                  accent=spec.get("accent", "red"), contrast=spec.get("contrast", 1.1), seed=len(key),
                                  dots=spec.get("dots", True))
                piece.save(out)
                print("  piece", key)
            done[key] = os.path.relpath(out, proj).replace("\\", "/")
        # one sheet of every cut-out, so a bad cut is caught before it reaches a scene
        from PIL import Image
        tiles = []
        for key, rel in done.items():
            im = Image.open(os.path.join(proj, rel))
            im.thumbnail((360, 420))
            t = Image.new("RGBA", (380, 440), (90, 110, 110, 255))
            t.alpha_composite(im, ((380 - im.width) // 2, (440 - im.height) // 2))
            tiles.append(t)
        if tiles:
            cols = 4
            sheet = Image.new("RGB", (380 * cols, 440 * ((len(tiles) + cols - 1) // cols)), (60, 60, 60))
            for i, t in enumerate(tiles):
                sheet.paste(t.convert("RGB"), ((i % cols) * 380, (i // cols) * 440))
            sheet.save(os.path.join(root, "pieces.jpg"), quality=85)
        return done
    
    
    def narrate(story, proj):
        ad = os.path.join(proj, "assets", "audio")
        os.makedirs(ad, exist_ok=True)
        key = _h([[b["say"] for b in story["beats"]], story.get("voice", "af_heart"), story.get("speed", 1.0)])
        cache = os.path.join(ad, "timing.json")
        if os.path.exists(cache):
            c = json.load(open(cache))
            if c.get("key") == key:
                return c["timing"], c["words"], c["total"]
        vo, timing, total = voice.synth(story["beats"], ad, story.get("voice", "af_heart"), story.get("speed", 1.0),
                                        lang=story.get("lang", "en-us"))
        words = voice.words(vo, story["beats"], timing)
        json.dump({"key": key, "timing": timing, "words": words, "total": total}, open(cache, "w"), indent=1)
        print(f"  voice {total:.1f}s, {len(timing)} beats")
        return timing, words, total
    
    
    def mix(story, proj, events, total):
        ad = os.path.join(proj, "assets", "audio")
        vo, sr = sf.read(os.path.join(ad, "vo.wav"), dtype="float32")
        vo = resample_poly(vo, 2, 1) if sr == 24000 else vo
        n = int((total + 0.5) * sfx.SR)
        out = np.zeros(n, np.float32)
        out[: min(n, len(vo))] += vo[:n]
        fx = sfx.make_events(events, total)[:n]
        out[: len(fx)] += fx * story.get("sfx_gain", 0.32)
        if story.get("music", "bed") == "bed":
            bd = sfx.bed(total, bpm=story.get("bpm", 72))[:n]
            out[: len(bd)] += bd * story.get("music_gain", 0.22)
        elif story.get("music") not in (None, "none"):
            m, msr = sf.read(os.path.join(os.path.dirname(proj), story["music"]), dtype="float32")
            if m.ndim > 1:
                m = m.mean(1)
            if msr != sfx.SR:
                m = resample_poly(m, sfx.SR, msr)
            m = m[:n]
            out[: len(m)] += m * story.get("music_gain", 0.12)
        raw = os.path.join(ad, "mix_raw.wav")
        sf.write(raw, out / (np.max(np.abs(out)) or 1) * 0.7, sfx.SR)
        subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", raw, "-af",
                        "loudnorm=I=-14:TP=-1.5:LRA=9,alimiter=limit=0.84:level=0", "-ar", "48000",
                        os.path.join(ad, "mix.wav")], check=True)
    
    
    def make(path, render=False):
        story, root, proj = load(path)
        os.makedirs(proj, exist_ok=True)
        print("images")
        imgs = images(story, root, proj)
        print("voice")
        timing, words, total = narrate(story, proj)
        print("animation")
        events = build.write_project(story, timing, words, total, proj, imgs)
        print("sound")
        mix(story, proj, events, total)
        r = subprocess.run([NPX, "hyperframes", "check", proj], capture_output=True, text=True, encoding="utf-8", errors="replace")
        errs = [ln.strip() for ln in (r.stdout + r.stderr).splitlines() if ln.strip().startswith("✗")]
        print(f"check: {len(errs)} errors")
        for e in errs[:10]:
            print("  ", e[:220])
        if render:
            out = os.path.join(root, story.get("slug", "video") + ".mp4")
            subprocess.run([NPX, "hyperframes", "render", proj, "-o", out, "-q", "delivery", "--quiet"], check=True)
            print("rendered", out)
        return proj
    
    
    if __name__ == "__main__":
        sys.stdout.reconfigure(encoding="utf-8")
        cmd = sys.argv[1]
        if cmd == "make":
            make(sys.argv[2], render="--render" in sys.argv)
        elif cmd == "search":
            c = fetch.search(sys.argv[2], allow_by="--allow-by" in sys.argv)
            for i, x in enumerate(c, 1):
                print(f"{i:2d}. {x['title']}  [{x['license']}]  {x['w']}x{x['h']}")
            if len(sys.argv) > 3 and not sys.argv[3].startswith("--"):
                fetch.contact_sheet(c, sys.argv[3])
                print("sheet:", sys.argv[3])
        elif cmd == "snap":
            story, root, proj = load(sys.argv[2])
            out = os.path.join(root, "snaps")
            subprocess.run([NPX, "hyperframes", "snapshot", proj, "--at", sys.argv[3], "-o", out, "--no-end"], check=True)
            print(out)
    
  • engine/sfx.py4.6 KBc57043b68c0cupstream ↗
    """Paper foley and a documentary bed, synthesised from noise and sine waves: no sample packs, nothing to license.
    
    events: [{"t": 1.23, "k": "slide"|"drop"|"stamp"|"tape"|"wipe"|"type"|"draw"|"pin"}]
    """
    import numpy as np
    import soundfile as sf
    from scipy.signal import butter, sosfilt
    
    SR = 48000
    
    
    def _bp(x, lo, hi, order=2):
        sos = butter(order, [lo, hi], btype="band", fs=SR, output="sos")
        return sosfilt(sos, x)
    
    
    def _lp(x, f, order=2):
        return sosfilt(butter(order, f, btype="low", fs=SR, output="sos"), x)
    
    
    def _env(n, a, d, curve=3.0):
        t = np.arange(n) / SR
        e = np.minimum(1, t / max(a, 1e-4)) * np.exp(-np.maximum(0, t - a) / max(d, 1e-4) * curve / 3)
        return e
    
    
    def paper_slide(rng, dur=0.28):
        n = int(dur * SR)
        x = rng.standard_normal(n)
        # paper friction: band noise with a grainy amplitude flutter
        flutter = 0.6 + 0.4 * np.abs(_lp(rng.standard_normal(n), 60))
        x = _bp(x, 1800, 7000) * flutter
        e = np.sin(np.linspace(0, np.pi, n)) ** 1.5
        return x * e * 0.35
    
    
    def tap(rng, dur=0.12, body=180):
        n = int(dur * SR)
        t = np.arange(n) / SR
        click = _bp(rng.standard_normal(n), 2000, 8000) * _env(n, 0.001, 0.012)
        thump = np.sin(2 * np.pi * body * t) * _env(n, 0.002, 0.05)
        return click * 0.5 + thump * 0.45
    
    
    def stamp(rng):
        n = int(0.3 * SR)
        t = np.arange(n) / SR
        thud = np.sin(2 * np.pi * (95 - 30 * t) * t) * _env(n, 0.002, 0.12)
        slap = _bp(rng.standard_normal(n), 400, 3000) * _env(n, 0.001, 0.03)
        return thud * 0.9 + slap * 0.6
    
    
    def tape_rip(rng, dur=0.35):
        n = int(dur * SR)
        x = _bp(rng.standard_normal(n), 2500, 9000)
        crackle = (rng.random(n) < 0.02) * rng.standard_normal(n) * 3
        e = _env(n, 0.03, dur * 0.6)
        return (x + _bp(crackle, 1500, 9000)) * e * 0.3
    
    
    def wipe(rng, dur=0.42):
        n = int(dur * SR)
        x = rng.standard_normal(n)
        lo = _bp(x, 300, 2500)
        hi = _bp(x, 2500, 9000)
        e = np.sin(np.linspace(0, np.pi, n)) ** 0.8
        return (lo * 0.5 + hi * 0.35) * e * 0.5
    
    
    def typewriter(rng, n_keys=12, rate=14):
        out = np.zeros(int((n_keys / rate + 0.2) * SR))
        for i in range(n_keys):
            k = tap(rng, 0.06, body=900 + rng.random() * 300) * (0.5 + 0.3 * rng.random())
            s = int((i / rate + rng.normal(0, 0.008)) * SR)
            s = max(0, s)
            out[s:s + len(k)] += k[: len(out) - s]
        return out * 0.7
    
    
    def marker(rng, dur=0.5):
        n = int(dur * SR)
        x = _bp(rng.standard_normal(n), 3000, 10000)
        e = (0.5 + 0.5 * np.sin(np.linspace(0, 6 * np.pi, n))) * np.sin(np.linspace(0, np.pi, n))
        return x * e * 0.18
    
    
    def make_events(events, total, seed=7):
        rng = np.random.default_rng(seed)
        mix = np.zeros(int((total + 1) * SR))
        for ev in events:
            k = ev["k"]
            x = {"slide": lambda: paper_slide(rng), "drop": lambda: tap(rng), "pin": lambda: tap(rng, 0.08, 1200),
                 "stamp": lambda: stamp(rng), "tape": lambda: tape_rip(rng), "wipe": lambda: wipe(rng),
                 "type": lambda: typewriter(rng, ev.get("n", 12)), "draw": lambda: marker(rng)}.get(k, lambda: tap(rng))()
            x = x * ev.get("g", 1.0)
            s = int(max(0, ev["t"]) * SR)
            e = min(len(mix), s + len(x))
            mix[s:e] += x[: e - s]
        return mix
    
    
    def bed(total, seed=3, bpm=72):
        """Low documentary tension: a slow minor pad, a soft heartbeat pulse and a clock tick."""
        rng = np.random.default_rng(seed)
        n = int((total + 1) * SR)
        t = np.arange(n) / SR
        # chord roots (A minor -> F -> C -> E), one chord per 4 beats
        roots = [110.0, 87.31, 130.81, 82.41]
        bar = 4 * 60 / bpm
        out = np.zeros(n)
        for i, r in enumerate(roots * int(total / (bar * 4) + 2)):
            a = int(i * bar * SR)
            if a >= n:
                break
            z = min(n, a + int(bar * SR * 1.15))
            tt = t[a:z] - t[a]
            env = np.minimum(1, tt / 0.8) * np.minimum(1, (tt[-1] - tt) / 0.6 + 0.0)
            tones = sum(np.sin(2 * np.pi * f * tt + p) for f, p in
                        [(r, 0), (r * 1.003, 1), (r * 1.5, 2), (r * 2.38, 0.5), (r * 0.5, 0.2)])
            out[a:z] += tones * env * 0.08
        out = _lp(out, 1400)
        beat = 60 / bpm
        for k in range(int(total / beat) + 1):
            s = int(k * beat * SR)
            hb = tap(rng, 0.25, body=55) * 0.5
            e = min(n, s + len(hb))
            out[s:e] += hb[: e - s] * 0.6
            tk = _bp(rng.standard_normal(int(0.02 * SR)), 3000, 8000) * _env(int(0.02 * SR), 0.0005, 0.004)
            s2 = int((k * beat + beat / 2) * SR)
            e2 = min(n, s2 + len(tk))
            out[s2:e2] += tk[: e2 - s2] * 0.12
        fade = np.minimum(1, t / 1.5) * np.minimum(1, (t[-1] - t) / 1.5)
        return out * fade
    
    
    def write(path, x):
        peak = np.max(np.abs(x)) or 1
        sf.write(path, (x / peak * 0.8).astype(np.float32), SR)
    
  • engine/voice.py3.7 KB836d7e21e9c8upstream ↗
    """Narration with Kokoro (Apache-2.0, runs on the CPU, free for commercial use) + word timings for captions.
    
    One wav per beat, so every beat's length is exact. Captions use the script's own words, timed by faster-whisper
    (MIT): the script text is mapped onto whisper's word clock by character position, so spelling always matches.
    """
    import json
    import os
    import re
    
    import numpy as np
    import soundfile as sf
    
    
    def _models():
        for d in (os.environ.get("PAPERCUT_KOKORO"), os.path.join(os.path.expanduser("~"), ".papercut", "kokoro"), r"C:\kokoro\models"):
            if d and os.path.exists(os.path.join(d, "kokoro-v1.0.onnx")):
                return d
        raise SystemExit("Kokoro models not found: run setup (it downloads them to ~/.papercut/kokoro)")
    
    
    GAP = 0.18  # silence between beats (s)
    
    
    def synth(beats, out_dir, voice="af_heart", speed=1.0, lang="en-us"):
        from kokoro_onnx import Kokoro
        km = _models()
        k = Kokoro(os.path.join(km, "kokoro-v1.0.onnx"), os.path.join(km, "voices-v1.0.bin"))
        os.makedirs(out_dir, exist_ok=True)
        clips, sr = [], 24000
        for i, b in enumerate(beats):
            x, sr = k.create(b["say"], voice=voice, speed=speed, lang=lang)
            x = np.asarray(x, np.float32)
            # trim Kokoro's own leading/trailing silence so beats sit tight, then add a fixed gap
            nz = np.where(np.abs(x) > 0.004)[0]
            if len(nz):
                x = x[max(0, nz[0] - int(0.02 * sr)): nz[-1] + int(0.06 * sr)]
            clips.append(x)
        t, timing, parts = 0.25, [], [np.zeros(int(0.25 * sr), np.float32)]
        for x in clips:
            d = len(x) / sr
            timing.append({"start": round(t, 3), "dur": round(d, 3)})
            parts += [x, np.zeros(int(GAP * sr), np.float32)]
            t += d + GAP
        parts.append(np.zeros(int(0.6 * sr), np.float32))
        full = np.concatenate(parts)
        path = os.path.join(out_dir, "vo.wav")
        sf.write(path, full, sr, subtype="PCM_16")
        return path, timing, len(full) / sr
    
    
    def words(vo_path, beats, timing, model="small"):
        """Script tokens with start/end times, grouped per beat."""
        from faster_whisper import WhisperModel
        m = WhisperModel(model, device="cpu", compute_type="int8")
        from scipy.signal import resample_poly
        x, sr = sf.read(vo_path, dtype="float32")
        x = resample_poly(x, 16000, sr).astype(np.float32)  # hand whisper an array: avoids PyAV version clashes
        segs, _ = m.transcribe(x, word_timestamps=True, language="en")
        ww = [w for s in segs for w in (s.words or [])]
        out = []
        for b, tm in zip(beats, timing):
            a, z = tm["start"], tm["start"] + tm["dur"]
            inside = [w for w in ww if w.start >= a - 0.15 and w.end <= z + 0.25]
            toks = b["say"].split()
            total = max(1, sum(len(re.sub(r"\W", "", t)) or 1 for t in toks))
            # whisper clock: cumulative chars -> time (even spacing if whisper heard nothing here)
            xs, ts = [0], [inside[0].start if inside else a]
            c = 0
            for w in inside:
                c += len(re.sub(r"\W", "", w.word)) or 1
                xs.append(c)
                ts.append(w.end)
            if not inside:
                xs, ts = [0, total], [a, z]
            scale = xs[-1] / total if xs[-1] else 1
            cur, row = 0, []
            for t in toks:
                n = len(re.sub(r"\W", "", t)) or 1
                s = float(np.interp(cur * scale, xs, ts))
                e = float(np.interp((cur + n) * scale, xs, ts))
                row.append({"w": t, "s": round(s, 3), "e": round(max(e, s + 0.08), 3)})
                cur += n
            out.append(row)
        return out
    
    
    if __name__ == "__main__":
        import sys
        story = json.load(open(sys.argv[1], encoding="utf-8"))
        p, tm, total = synth(story["beats"], "assets/audio", story.get("voice", "af_heart"))
        print(p, round(total, 2), tm)
    
  • examples/01-first-bug/CREDITS.txt1.0 KB5f7b62de9c0eupstream ↗
    hopper.jpg: File:Commodore Grace M. Hopper, USN (covered) head and shoulders crop.jpg | James S. Davis | Public domain | https://commons.wikimedia.org/wiki/File:Commodore_Grace_M._Hopper,_USN_(covered)_head_and_shoulders_crop.jpg
    edison.jpg: File:Portrait of Thomas Edison. (409fa731e3364ed99012ccedfdce3e28).jpg | Unknown authorUnknown author | Public domain | https://commons.wikimedia.org/wiki/File:Portrait_of_Thomas_Edison._(409fa731e3364ed99012ccedfdce3e28).jpg
    tape.jpg: File:First Computer Bug, 1947.jpg | Courtesy of the Naval Surface Warfare Center, Dahlgren, VA., 1988. | Public domain | https://commons.wikimedia.org/wiki/File:First_Computer_Bug,_1947.jpg
    line.jpg: File:First Computer Bug, 1947.jpg | Courtesy of the Naval Surface Warfare Center, Dahlgren, VA., 1988. | Public domain | https://commons.wikimedia.org/wiki/File:First_Computer_Bug,_1947.jpg
    page.jpg: File:First Computer Bug, 1947.jpg | Courtesy of the Naval Surface Warfare Center, Dahlgren, VA., 1988. | Public domain | https://commons.wikimedia.org/wiki/File:First_Computer_Bug,_1947.jpg
    
  • examples/01-first-bug/story.json2.8 KB44b61cfa40dfupstream ↗
    {
      "title": "The First Computer Bug",
      "slug": "first-computer-bug",
      "format": "9:16",
      "voice": "af_heart",
      "speed": 1.0,
      "music": "bed",
      "images": {
        "tape": {"commons": "File:First Computer Bug, 1947.jpg", "crop": [0.115, 0.535, 0.45, 0.715], "cut": false, "accent": "mustard", "size": 1100, "dots": false},
        "line": {"commons": "File:First Computer Bug, 1947.jpg", "crop": [0.13, 0.69, 0.79, 0.785], "cut": false, "accent": null, "size": 1200, "dots": false},
        "page": {"commons": "File:First Computer Bug, 1947.jpg", "cut": false, "accent": "red", "size": 1200, "dots": false},
        "hopper": {"commons": "File:Commodore Grace M. Hopper, USN (covered) head and shoulders crop.jpg", "cut": true, "accent": "red"},
        "edison": {"commons": "File:Portrait of Thomas Edison. (409fa731e3364ed99012ccedfdce3e28).jpg", "cut": true, "accent": "teal"}
      },
      "beats": [
        {"say": "September ninth, 1947. Harvard University.", "kind": "document", "bg": "mustard",
         "head": "OPERATIONS LOG", "lines": ["DATE: 9 SEPT 1947", "MACHINE: MARK II", "STATUS: RUNNING"], "label": "09.09.1947"},
        {"say": "Mid-afternoon, one of the world's first computers starts making errors.", "kind": "number", "bg": "red",
         "number": "15:45", "sub": "ERRORS", "stamp": "FAULT"},
        {"say": "The team opens Panel F and checks the relays, one by one.", "kind": "timeline", "bg": "teal",
         "dates": ["#68", "#69", "#70", "#71"], "active": 2, "label": "PANEL F"},
        {"say": "In relay number seventy, they find it.", "kind": "number", "bg": "navy", "number": "#70", "stamp": "FOUND", "scraps": 3},
        {"say": "A moth.", "kind": "photo", "bg": "newsprint", "photo": "tape", "label": "A MOTH", "burst": true, "burst_color": "mustard"},
        {"say": "They tape it into the logbook, and write one line.", "kind": "photo", "bg": "kraft", "photo": "page", "tag": "THE REAL LOGBOOK PAGE"},
        {"say": "First actual case of bug being found.", "kind": "photo", "bg": "mustard", "photo": "line", "label": "THE NOTE", "stamp": "1947", "stamp_on": "found"},
        {"say": "Grace Hopper told this story for decades, and the word stuck.", "kind": "hero", "bg": "red", "hero": "hopper",
         "label": "GRACE HOPPER", "tag": "U.S. NAVY", "burst": true},
        {"say": "But engineers were already calling glitches bugs in Thomas Edison's time.", "kind": "timeline", "bg": "teal",
         "dates": ["1878", "1947", "TODAY"], "active": 0, "hero": "edison", "label": "EDISON"},
        {"say": "The moth just made it literal.", "kind": "hero", "bg": "newsprint", "hero": "tape", "circle": true, "label": "LITERALLY"},
        {"say": "That page is now kept at the Smithsonian.", "kind": "photo", "bg": "navy", "photo": "page", "stamp": "SMITHSONIAN", "stamp_on": "Smithsonian"},
        {"say": "Seventy-nine years later, we are still debugging.", "kind": "number", "bg": "red", "number": "79 YEARS", "sub": "STILL DEBUGGING"}
      ]
    }
    
  • examples/02-apollo-13/CREDITS.txt938 B751328f835a3upstream ↗
    sm.jpg: File:Apollo 13 SM.jpg | NASA Astronaut (either James A. Lovell, John L. Swigert, or Fred W. Haise) | Public domain | https://commons.wikimedia.org/wiki/File:Apollo_13_SM.jpg
    crew.jpg: File:Apollo 13 Prime Crew.jpg | NASA | Public domain | https://commons.wikimedia.org/wiki/File:Apollo_13_Prime_Crew.jpg
    swigert.jpg: File:Jack Swigert.jpg | NASA | Public domain | https://commons.wikimedia.org/wiki/File:Jack_Swigert.jpg
    lm.jpg: File:Apollo 13 Lunar Module after separation from CSM (AS13-59-8562).jpg | NASA | Public domain | https://commons.wikimedia.org/wiki/File:Apollo_13_Lunar_Module_after_separation_from_CSM_(AS13-59-8562).jpg
    mailbox.jpg: File:Apollo 13 Mailbox at Mission Control.jpg | NASA | Public domain | https://commons.wikimedia.org/wiki/File:Apollo_13_Mailbox_at_Mission_Control.jpg
    splash.jpg: File:Apollo13 splashdown.jpg | NASA | Public domain | https://commons.wikimedia.org/wiki/File:Apollo13_splashdown.jpg
    
  • examples/02-apollo-13/story.json2.9 KBb05060f2846aupstream ↗
    {
      "title": "Apollo 13", "slug": "apollo-13", "format": "9:16", "voice": "af_heart", "music": "bed", "seed": 21,
      "images": {
        "sm": {"commons": "File:Apollo 13 SM.jpg", "cut": false, "accent": "red", "size": 1100},
        "crew": {"commons": "File:Apollo 13 Prime Crew.jpg", "cut": true, "accent": "mustard", "size": 1100},
        "swigert": {"commons": "File:Jack Swigert.jpg", "cut": true, "accent": "red", "crop": [0.12, 0.05, 0.9, 0.75]},
        "lm": {"commons": "File:Apollo 13 Lunar Module after separation from CSM (AS13-59-8562).jpg", "cut": false, "accent": "teal", "crop": [0.1, 0.12, 0.9, 0.88], "size": 1000},
        "mailbox": {"commons": "File:Apollo 13 Mailbox at Mission Control.jpg", "cut": false, "accent": "mustard", "size": 1100},
        "splash": {"commons": "File:Apollo13 splashdown.jpg", "cut": false, "accent": "red", "size": 1000}
      },
      "beats": [
        {"say": "April thirteenth, 1970. Two hundred thousand miles from Earth.", "kind": "number", "bg": "navy", "number": "200,000 MI", "sub": "13.04.1970"},
        {"say": "An oxygen tank on Apollo Thirteen explodes.", "kind": "photo", "bg": "red", "photo": "sm", "label": "APOLLO 13", "stamp": "EXPLOSION", "stamp_on": "explodes"},
        {"say": "Jack Swigert radios: Okay, Houston, we've had a problem here.", "kind": "quote", "bg": "mustard", "hero": "swigert", "quote": "Houston, we've had a problem here.", "by": "Jack Swigert, 1970"},
        {"say": "The moon landing is off. Now the only goal is getting three men home.", "kind": "hero", "bg": "teal", "hero": "crew", "label": "LOVELL · SWIGERT · HAISE", "label_size": 70, "stamp": "GET HOME", "burst": true},
        {"say": "They power down the command module and move into the lunar lander.", "kind": "photo", "bg": "mustard", "photo": "lm", "label": "AQUARIUS", "tag": "THE LUNAR MODULE"},
        {"say": "A lifeboat built for two people, now carrying three.", "kind": "number", "bg": "kraft", "number": "2 → 3", "sub": "BUILT FOR TWO", "stamp": "OVERLOAD"},
        {"say": "Then the carbon dioxide starts rising.", "kind": "number", "bg": "red", "number": "CO2", "sub": "RISING", "scraps": 3},
        {"say": "Engineers on the ground build a fix from plastic bags, cardboard and tape.", "kind": "document", "bg": "mustard", "head": "THE FIX", "lines": ["1. PLASTIC BAGS", "2. CARDBOARD", "3. DUCT TAPE"], "mark": 2},
        {"say": "They call it the mailbox. The crew copies it, piece by piece.", "kind": "photo", "bg": "teal", "photo": "mailbox", "label": "THE MAILBOX", "tag": "MISSION CONTROL, 1970"},
        {"say": "April seventeenth. Splashdown in the South Pacific.", "kind": "photo", "bg": "navy", "photo": "splash", "label": "17.04.1970", "stamp": "HOME", "stamp_on": "Pacific"},
        {"say": "Apollo Thirteen never touched the moon.", "kind": "timeline", "bg": "newsprint", "dates": ["LAUNCH", "MOON", "HOME"], "active": 2, "label": "NO LANDING"},
        {"say": "It is still called a successful failure.", "kind": "quote", "bg": "red", "quote": "A successful failure.", "by": "Apollo 13, 1970"}
      ]
    }
    
  • examples/03-mona-lisa/CREDITS.txt920 Bdc112a7ece27upstream ↗
    wall.jpg: File:Mona Lisa stolen-1911.jpg | Unknown authorUnknown author | Public domain | https://commons.wikimedia.org/wiki/File:Mona_Lisa_stolen-1911.jpg
    peruggia.jpg: File:Vincenzo peruggia.jpg | Unknown authorUnknown author | Public domain | https://commons.wikimedia.org/wiki/File:Vincenzo_peruggia.jpg
    mona.jpg: File:Mona Lisa, by Leonardo da Vinci, from C2RMF retouched.jpg | Leonardo da Vinci | Public domain | https://commons.wikimedia.org/wiki/File:Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg
    picasso.jpg: File:Pablo Picasso in his Montmartre studio, 1908.jpg | Gelett Burgess | Public domain | https://commons.wikimedia.org/wiki/File:Pablo_Picasso_in_his_Montmartre_studio,_1908.jpg
    monacut.jpg: File:Mona Lisa, by Leonardo da Vinci, from C2RMF retouched.jpg | Leonardo da Vinci | Public domain | https://commons.wikimedia.org/wiki/File:Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg
    
  • examples/03-mona-lisa/story.json2.7 KB23b7ec5ed81dupstream ↗
    {
      "title": "The Mona Lisa Theft", "slug": "mona-lisa-theft", "format": "9:16", "voice": "af_heart", "music": "bed", "seed": 33,
      "images": {
        "wall": {"commons": "File:Mona Lisa stolen-1911.jpg", "cut": false, "accent": "red", "size": 900},
        "peruggia": {"commons": "File:Vincenzo peruggia.jpg", "cut": true, "accent": "red"},
        "mona": {"commons": "File:Mona Lisa, by Leonardo da Vinci, from C2RMF retouched.jpg", "cut": false, "accent": "mustard", "size": 1000},
        "picasso": {"commons": "File:Pablo Picasso in his Montmartre studio, 1908.jpg", "cut": true, "accent": "teal", "crop": [0.47, 0.28, 1.0, 0.86], "contrast": 1.3},
        "monacut": {"commons": "File:Mona Lisa, by Leonardo da Vinci, from C2RMF retouched.jpg", "cut": true, "accent": "red", "size": 1000}
      },
      "beats": [
        {"say": "August 1911. The Louvre, Paris.", "kind": "map", "bg": "mustard", "map": {"country": "France", "pin": [2.34, 48.86], "place": "THE LOUVRE", "sea": "teal"}, "label": "08.1911"},
        {"say": "A painter comes to sketch the Mona Lisa, and finds an empty wall.", "kind": "photo", "bg": "red", "photo": "wall", "label": "AN EMPTY WALL", "tag": "THE LOUVRE, 1911"},
        {"say": "The painting has been gone for more than a day.", "kind": "number", "bg": "navy", "number": "24+ HRS", "sub": "MISSING", "stamp": "STOLEN"},
        {"say": "Police question hundreds of people, even Pablo Picasso.", "kind": "hero", "bg": "teal", "hero": "picasso", "label": "PICASSO", "stamp": "QUESTIONED", "stamp_on": "Picasso"},
        {"say": "But the thief is a Louvre worker named Vincenzo Peruggia.", "kind": "hero", "bg": "newsprint", "hero": "peruggia", "label": "THE THIEF", "tag": "VINCENZO PERUGGIA", "circle": true},
        {"say": "He walks out with it, hidden under his work smock.", "kind": "document", "bg": "kraft", "head": "POLICE REPORT", "lines": ["ITEM: MONA LISA", "EXIT: STAFF STAIRS", "COVER: WORK SMOCK"], "redact": [1]},
        {"say": "For more than two years, the Mona Lisa sits in his room in Paris.", "kind": "number", "bg": "red", "number": "2 YEARS", "hero": "mona"},
        {"say": "In 1913, he takes it to an art dealer in Florence.", "kind": "map", "bg": "mustard", "map": {"highlight": ["France", "Italy"], "pin": [11.25, 43.77], "place": "FLORENCE", "sea": "navy"}, "label": "1913"},
        {"say": "The dealer calls the police.", "kind": "hero", "bg": "navy", "hero": "peruggia", "stamp": "ARRESTED", "stamp_on": "police"},
        {"say": "Before the theft, it was not the world's most famous painting.", "kind": "photo", "bg": "newsprint", "photo": "mona", "stamp": "NOT YET", "stamp_on": "famous"},
        {"say": "After it, it was.", "kind": "hero", "bg": "red", "hero": "monacut", "label": "MOST FAMOUS", "burst": true}
      ]
    }
    
  • examples/04-eniac/CREDITS.txt868 Bc4fadc7d51deupstream ↗
    room.jpg: File:Classic shot of the ENIAC.jpg | Unidentified U.S. Army photographer | Public domain | https://commons.wikimedia.org/wiki/File:Classic_shot_of_the_ENIAC.jpg
    two.jpg: File:Two women operating ENIAC (full resolution).jpg | Unidentified U.S. Army photographer | Public domain | https://commons.wikimedia.org/wiki/File:Two_women_operating_ENIAC_(full_resolution).jpg
    snyder.jpg: File:Glen Beck and Betty Snyder program the ENIAC in building 328 at the Ballistic Research Laboratory.jpg | Unknown authorUnknown author | Public domain | https://commons.wikimedia.org/wiki/File:Glen_Beck_and_Betty_Snyder_program_the_ENIAC_in_building_328_at_the_Ballistic_Research_Laboratory.jpg
    jennings.jpg: File:Betty Jennings (Mrs. Bartik).jpg | Unidentified U.S. Army photographer | Public domain | https://commons.wikimedia.org/wiki/File:Betty_Jennings_(Mrs._Bartik).jpg
    
  • examples/04-eniac/story.json2.9 KB5c5b6ef9e3e2upstream ↗
    {
     "title": "ENIAC's Six Programmers",
     "slug": "eniac-programmers",
     "format": "9:16",
     "voice": "af_heart",
     "music": "bed",
     "seed": 44,
     "images": {
      "room": {
       "commons": "File:Classic shot of the ENIAC.jpg",
       "cut": false,
       "accent": "red",
       "size": 1000
      },
      "two": {
       "commons": "File:Two women operating ENIAC (full resolution).jpg",
       "cut": false,
       "accent": "mustard",
       "size": 1100
      },
      "snyder": {
       "commons": "File:Glen Beck and Betty Snyder program the ENIAC in building 328 at the Ballistic Research Laboratory.jpg",
       "cut": false,
       "accent": "teal",
       "size": 1000
      },
      "jennings": {
       "commons": "File:Betty Jennings (Mrs. Bartik).jpg",
       "cut": false,
       "accent": "mustard",
       "crop": [
        0.0,
        0.0,
        1.0,
        0.62
       ],
       "size": 1000
      }
     },
     "beats": [
      {
       "say": "February 1946. Philadelphia.",
       "kind": "photo",
       "bg": "mustard",
       "photo": "room",
       "label": "02.1946"
      },
      {
       "say": "The US Army shows off ENIAC, a computer that weighs thirty tons.",
       "kind": "number",
       "bg": "red",
       "number": "30 TONS",
       "sub": "ENIAC"
      },
      {
       "say": "Newspapers call it a giant brain.",
       "kind": "document",
       "bg": "newsprint",
       "head": "THE PRESS",
       "lines": [
        "\"GIANT BRAIN\"",
        "ARMY UNVEILS",
        "ELECTRONIC COMPUTER"
       ],
       "mark": 0
      },
      {
       "say": "But six women had programmed it, with no manuals, using cables and switches.",
       "kind": "photo",
       "bg": "teal",
       "photo": "two",
       "label": "SIX PROGRAMMERS",
       "label_size": 90
      },
      {
       "say": "Kay McNulty, Jean Jennings, Betty Snyder, Marlyn Wescoff, Fran Bilas and Ruth Lichterman.",
       "kind": "document",
       "bg": "mustard",
       "head": "THE TEAM",
       "lines": [
        "KAY McNULTY",
        "JEAN JENNINGS",
        "BETTY SNYDER",
        "MARLYN WESCOFF",
        "FRAN BILAS",
        "RUTH LICHTERMAN"
       ]
      },
      {
       "say": "At the big demonstration, none of them were introduced.",
       "kind": "photo",
       "bg": "red",
       "tag": "JEAN JENNINGS",
       "stamp": "NOT INTRODUCED",
       "stamp_size": 76,
       "stamp_on": "introduced",
       "photo": "jennings"
      },
      {
       "say": "Decades later, a researcher was told they were just models posing with the machine.",
       "kind": "photo",
       "bg": "navy",
       "photo": "snyder",
       "stamp": "MODELS?",
       "tag": "BETTY SNYDER AT THE ENIAC"
      },
      {
       "say": "Their names stayed almost unknown for half a century.",
       "kind": "timeline",
       "bg": "kraft",
       "dates": [
        "1946",
        "1970",
        "1997"
       ],
       "active": 2,
       "label": "50 YEARS"
      },
      {
       "say": "In 1997, all six entered the Women in Technology Hall of Fame.",
       "kind": "number",
       "bg": "teal",
       "number": "1997",
       "sub": "HALL OF FAME"
      },
      {
       "say": "Every programmer today works in a job they helped invent.",
       "kind": "photo",
       "bg": "red",
       "label": "THE FIRST PROGRAMMERS",
       "label_size": 76,
       "burst": true,
       "photo": "two"
      }
     ]
    }
  • LICENSE1.1 KB49b78646fd1aupstream ↗
    MIT License
    
    Copyright (c) 2026 Bashar Mokdad (Web Lab)
    
    Permission is hereby granted, free of charge, to any person obtaining a copy
    of this software and associated documentation files (the "Software"), to deal
    in the Software without restriction, including without limitation the rights
    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    copies of the Software, and to permit persons to whom the Software is
    furnished to do so, subject to the following conditions:
    
    The above copyright notice and this permission notice shall be included in all
    copies or substantial portions of the Software.
    
    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    SOFTWARE.
    
  • requirements.txt114 B2ee8f1139ba3upstream ↗
    kokoro-onnx>=0.4
    soundfile
    faster-whisper>=1.0
    rembg[cpu]>=2.0
    opencv-python-headless
    pillow
    numpy
    scipy
    requests
    
  • setup.ps11.7 KBfc8d79435407upstream ↗
    # papercut setup for Windows: venv + libraries + Kokoro voice model + map data and animation library.
    $ErrorActionPreference = "Stop"
    $root = $PSScriptRoot
    
    foreach ($tool in "python", "node", "npx", "ffmpeg") {
        if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { throw "$tool is not installed or not on PATH" }
    }
    
    if (-not (Test-Path "$root\.venv")) { python -m venv "$root\.venv" }
    & "$root\.venv\Scripts\python.exe" -m pip install -q -U pip
    & "$root\.venv\Scripts\python.exe" -m pip install -q -r "$root\requirements.txt"
    
    $models = Join-Path $HOME ".papercut\kokoro"
    New-Item -ItemType Directory -Force $models | Out-Null
    $rel = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0"
    foreach ($f in "kokoro-v1.0.onnx", "voices-v1.0.bin") {
        if (-not (Test-Path "$models\$f")) { Write-Host "downloading $f"; curl.exe -sL -o "$models\$f" "$rel/$f" }
    }
    
    $data = "$root\engine\data"
    New-Item -ItemType Directory -Force $data | Out-Null
    $libs = @{
        "gsap.min.js" = "https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"
        "d3-array.min.js" = "https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-array.min.js"
        "d3-geo.min.js" = "https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-geo.min.js"
        "topojson-client.min.js" = "https://cdn.jsdelivr.net/npm/[email protected]/dist/topojson-client.min.js"
        "countries-110m.json" = "https://cdn.jsdelivr.net/npm/[email protected]/countries-110m.json"
    }
    foreach ($k in $libs.Keys) { if (-not (Test-Path "$data\$k")) { curl.exe -sL -o "$data\$k" $libs[$k] } }
    
    npx --yes hyperframes --version | Out-Null
    Write-Host "papercut is ready. Try: .venv\Scripts\python.exe engine\papercut.py make examples\01-first-bug\story.json --render"
    
  • setup.sh1.4 KB09bfc1d5852cupstream ↗
    #!/usr/bin/env bash
    # papercut setup for macOS / Linux: venv + libraries + Kokoro voice model + map data and animation library.
    set -euo pipefail
    root="$(cd "$(dirname "$0")" && pwd)"
    
    for tool in python3 node npx ffmpeg curl; do
      command -v "$tool" >/dev/null || { echo "$tool is not installed"; exit 1; }
    done
    
    [ -d "$root/.venv" ] || python3 -m venv "$root/.venv"
    "$root/.venv/bin/python" -m pip install -q -U pip
    "$root/.venv/bin/python" -m pip install -q -r "$root/requirements.txt"
    
    models="$HOME/.papercut/kokoro"
    mkdir -p "$models"
    rel="https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0"
    for f in kokoro-v1.0.onnx voices-v1.0.bin; do
      [ -f "$models/$f" ] || { echo "downloading $f"; curl -sL -o "$models/$f" "$rel/$f"; }
    done
    
    data="$root/engine/data"
    mkdir -p "$data"
    get() { [ -f "$data/$1" ] || curl -sL -o "$data/$1" "$2"; }
    get gsap.min.js https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js
    get d3-array.min.js https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-array.min.js
    get d3-geo.min.js https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-geo.min.js
    get topojson-client.min.js https://cdn.jsdelivr.net/npm/[email protected]/dist/topojson-client.min.js
    get countries-110m.json https://cdn.jsdelivr.net/npm/[email protected]/countries-110m.json
    
    npx --yes hyperframes --version >/dev/null
    echo "papercut is ready. Try: .venv/bin/python engine/papercut.py make examples/01-first-bug/story.json --render"
    

Plain-text copy, no login: /skill-bundles/basharmokdad-papercut.md

// REPO STATS

2 stars