feat: first release !

This commit is contained in:
Kharec committed 2026-09-28 14:21:06 +02:00
1 parent 77a333e8d9
commit 664527182d
66 files changed
+13812 -16

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
// Drives Firefox (WebDriver BiDi, through puppeteer-core) to capture the
// README screenshots. Run by main.go, which serves the app at $URL and
// passes $OUT (where to write the PNGs), $PROFILE and $FIREFOX.
import puppeteer from 'puppeteer-core'
const { URL: base, OUT, PROFILE, FIREFOX } = process.env
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const browser = await puppeteer.launch({ browser: 'firefox', executablePath: FIREFOX, headless: true, userDataDir: PROFILE })
/** Opens the app on a script variant, with fresh preferences. */
async function open(variant = '') {
const page = await browser.newPage()
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 2 })
page.on('pageerror', (e) => console.error('page error:', e.message))
const url = base + (variant ? `?v=${variant}` : '')
await page.goto(url)
await page.evaluate(() => localStorage.clear())
await page.goto(url)
await sleep(1200) // pagination and spelling
await page.evaluate(() => { document.getElementById('scroller').style.scrollBehavior = 'auto' })
return page
}
const ctrl = async (page, key, shift = false) => {
const k = page.keyboard
await k.down('Control')
if (shift) await k.down('Shift')
await k.press(key)
if (shift) await k.up('Shift')
await k.up('Control')
}
/** Clicks the first element matching sel whose text contains text. */
async function clickText(page, sel, text) {
for (const el of await page.$$(sel)) if ((await el.evaluate((e) => e.textContent)).includes(text)) return el.click()
throw new Error(`no ${sel} containing "${text}"`)
}
/** Puts the cursor at the end of the first element containing text. */
async function caretIn(page, text) {
await page.evaluate((text) => {
const el = [...document.querySelectorAll('.ProseMirror .el')].find((e) => e.textContent.includes(text))
const r = document.createRange()
r.selectNodeContents(el)
r.collapse(false)
getSelection().removeAllRanges()
getSelection().addRange(r)
document.querySelector('.ProseMirror').focus()
}, text)
await sleep(100)
await page.keyboard.press('End')
}
/** Selects the first occurrence of text in the script. */
async function select(page, text) {
await page.evaluate((text) => {
const walker = document.createTreeWalker(document.querySelector('.ProseMirror'), NodeFilter.SHOW_TEXT)
for (let n; (n = walker.nextNode()); ) {
const i = n.textContent.indexOf(text)
if (i < 0) continue
const r = document.createRange()
r.setStart(n, i)
r.setEnd(n, i + text.length)
getSelection().removeAllRanges()
getSelection().addRange(r)
return
}
}, text)
}
const scrollTo = (page, y) => page.evaluate((y) => { document.getElementById('scroller').scrollTop = y }, y)
/** Captures the window, the pointer off the header (tooltips). */
async function shot(page, name, pointer = true) {
if (pointer) await page.mouse.move(1430, 600)
await sleep(400)
await page.screenshot({ path: `${OUT}/${name}.png` })
console.log('captured', name)
}
let page = await open()
await caretIn(page, 'It always ends')
await scrollTo(page, 0)
await shot(page, 'editor')
await ctrl(page, 'F', true)
await shot(page, 'focus-mode')
await ctrl(page, 'F', true)
await caretIn(page, 'FADE IN:')
await scrollTo(page, 0)
await page.evaluate(() => document.getElementById('type-button').dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
await shot(page, 'element-menu')
await page.keyboard.press('Escape')
await caretIn(page, 'The film snaps')
await page.keyboard.press('Enter')
await page.keyboard.press('Tab')
await page.keyboard.type('EL')
await scrollTo(page, 0)
await shot(page, 'autocomplete')
await page.keyboard.press('Escape')
for (let i = 0; i < 4; i++) await ctrl(page, 'z')
await sleep(1500)
await caretIn(page, 'THE END')
await page.keyboard.press('Enter')
await page.keyboard.type('INT. ')
await page.keyboard.type('PROJECTION BOOTH - ')
await sleep(1600) // autosave: "Saved · auto"
await page.evaluate(() => { const s = document.getElementById('scroller'); s.scrollTop = s.scrollHeight })
await shot(page, 'scene-autocomplete')
await page.close()
page = await open()
await caretIn(page, 'FADE IN:')
await scrollTo(page, 0)
await ctrl(page, 'h')
await sleep(100)
await page.keyboard.type('reel')
await page.keyboard.press('Enter')
await page.click('#replace-input')
await page.keyboard.type('spool')
await scrollTo(page, 0)
await shot(page, 'find-replace')
await page.close()
page = await open()
await caretIn(page, 'It always ends')
await page.evaluate(() => {
const s = document.getElementById('scroller')
s.scrollTop += document.querySelector('.page-break').getBoundingClientRect().top - 390
})
await shot(page, 'page-break')
await caretIn(page, 'FADE IN:')
await scrollTo(page, 0)
await ctrl(page, 'T', true)
await shot(page, 'title-page')
await page.keyboard.press('Escape')
await sleep(100)
await caretIn(page, 'FADE IN:')
await scrollTo(page, 0)
await ctrl(page, 'p')
await shot(page, 'export-pdf')
await page.keyboard.press('Escape')
await sleep(100)
await page.click('.switch') // autosave off, so the change stays unsaved
await caretIn(page, 'line every shelf.')
await page.keyboard.type(' Then silence.')
await scrollTo(page, 0)
await ctrl(page, 'n')
await shot(page, 'unsaved-changes')
await page.close()
page = await open('typos')
await caretIn(page, 'FADE IN:')
await scrollTo(page, 0)
await sleep(700)
const typo = await page.evaluate(() => {
const r = document.querySelector('.misspelled').getBoundingClientRect()
return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
})
await page.mouse.click(typo.x, typo.y, { button: 'right' })
await shot(page, 'spelling', false)
await page.close()
page = await open('outline')
await caretIn(page, 'Warmer light')
await scrollTo(page, 0)
await shot(page, 'outline')
// Locked pages, a Blue revision, then a Pink one, with the menu open.
await page.click('[data-action="revisions"]')
await sleep(150)
await clickText(page, 'ul.menu.popup li', 'Lock Pages')
await sleep(500)
await page.click('[data-action="revisions"]')
await sleep(150)
await clickText(page, 'ul.menu.popup li', 'Start Revisions')
await sleep(300)
await caretIn(page, 'line every shelf.')
await page.keyboard.type(' The lamp buzzes.')
await caretIn(page, 'every single night.')
await page.keyboard.press('Enter')
await page.keyboard.type('Then it starts again.')
await page.click('[data-action="revisions"]')
await sleep(150)
await clickText(page, 'ul.menu.popup li', 'New Revision Set')
await sleep(300)
await select(page, 'sleeves rolled up')
await page.keyboard.type('coat still on')
await sleep(900)
await scrollTo(page, 0)
await page.click('[data-action="revisions"]')
await sleep(250)
await shot(page, 'revisions')
await browser.close()
+330
View File
@@ -0,0 +1,330 @@
// Command screenshots regenerates the README images in screenshots/ from the
// sample script "The Last Reel". Run it from the repository root with
// `make screenshots`, which builds the frontend and installs puppeteer-core
// first.
//
// It serves frontend/dist with a stand-in for the Wails bridge whose calls are
// answered by the real internal packages, so pagination, locked pages and
// revision sets on the images are real. capture.mjs drives Firefox through it
// (1440×900, device scale 2); the window corners are then masked, since the
// browser cannot capture a transparent background. pdf-output.png is the
// PDF export of the script, rendered by pdftoppm and laid out on the app
// background.
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"log"
"math"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"plotzilla/internal/fountain"
"plotzilla/internal/layout"
"plotzilla/internal/pdf"
"plotzilla/internal/screenplay"
"plotzilla/internal/spell"
)
const (
dist = "frontend/dist"
sample = "tools/screenshots/the-last-reel.fountain"
)
// bridge replaces the Wails runtime in the page. Each call is posted to
// /api/<Method>, with the page's query string naming the script variant.
const bridge = `<script>
(() => {
const call = (name) => async (...args) => {
const r = await fetch('/api/' + name + location.search, { method: 'POST', body: JSON.stringify(args) })
return r.json()
}
window.go = { main: { App: new Proxy({}, { get: (_, n) => call(n) }) } }
window.runtime = { WindowSetTitle() {}, WindowMinimise() {}, WindowToggleMaximise() {},
WindowIsMaximised: async () => false, EventsOn: () => () => {}, Quit() {} }
// WebKitGTK does not spell-check the page; Firefox would.
document.documentElement.setAttribute('spellcheck', 'false')
})()
</script>
<style>*{animation:none!important;transition:none!important;caret-color:transparent!important}</style>
`
// typos are the misspellings of the "typos" variant, with their
// corrections: spelling.png needs no Hunspell dictionary.
var typos = map[string][]string{"projecter": {"projector", "projected"}, "thraeds": {"threads"}, "wacth": {"watch"}}
// variant returns the sample script, or one of its variants: "typos" for the
// spelling image, "outline" (sections, synopses, a note) for the outline and
// revision images.
func variant(src, name string) string {
var pairs []string
switch name {
case "typos":
pairs = []string{
"of the projector. ELODIE", "of the projecter. ELODIE",
"threads a reel by hand", "thraeds a reel by hand",
"Soldiers watch from", "Soldiers wacth from",
}
case "outline":
pairs = []string{
"FADE IN:\n", "# Act One\n\n= Elodie finds the reel her grandmother hid in 1942.\n\nFADE IN:\n",
"of the projector. ELODIE", "of the projector. [[Warmer light here?]] ELODIE",
"> PARIS, 1942 <\n", "> PARIS, 1942 <\n\n## The Rialto\n\n= The young woman smuggles the can into the cinema.\n",
"MATCH CUT TO:\n", "MATCH CUT TO:\n\n# Act Two\n",
}
}
for i := 0; i < len(pairs); i += 2 {
src = strings.Replace(src, pairs[i], pairs[i+1], 1)
}
return src
}
type server struct {
src string // the sample script
path string // its absolute path, shown as the file name
}
func (s *server) call(name, v string, raw []json.RawMessage) any {
doc := func() *screenplay.Document {
var d screenplay.Document
if len(raw) > 0 {
json.Unmarshal(raw[0], &d)
}
return &d
}
switch name {
case "NewDocument":
return screenplay.New()
case "InitialFile":
return map[string]any{"path": s.path, "document": fountain.Parse(variant(s.src, v))}
case "Save", "SaveAs":
return s.path // nothing is written
case "Paginate":
res := layout.Paginate(doc(), layout.Options{})
return map[string]any{"pages": len(res.Pages), "breaks": res.Breaks, "scenes": res.Scenes}
case "ParseFountain":
var text string
json.Unmarshal(raw[0], &text)
return fountain.ParseText(text)
case "RevisionSet":
var n int
json.Unmarshal(raw[0], &n)
r := screenplay.RevisionSet(n)
r.Date = []string{"09/14/26", "09/28/26"}[min(max(n, 1), 2)-1]
return r
case "WindowFrame":
return map[string]any{"buttons": "appmenu:close", "rounded": true}
case "SpellLanguages":
return map[string]any{"languages": []string{"en_US"}, "default": "en_US"}
case "SpellCheck":
out := []spell.Misspelling{}
for i, el := range doc().Elements {
for _, w := range spell.Words(el.Text()) {
if _, ok := typos[w.Text]; ok {
out = append(out, spell.Misspelling{Element: i, Offset: w.Offset, Length: w.Length, Word: w.Text})
}
}
}
return out
case "SpellSuggest":
var word string
json.Unmarshal(raw[0], &word)
return typos[word]
}
return nil // Recovery, SetDirty…
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if name, ok := strings.CutPrefix(r.URL.Path, "/api/"); ok {
var raw []json.RawMessage
json.NewDecoder(r.Body).Decode(&raw)
json.NewEncoder(w).Encode(s.call(name, r.URL.Query().Get("v"), raw))
return
}
if r.URL.Path == "/" {
html, err := os.ReadFile(filepath.Join(dist, "index.html"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, strings.Replace(string(html), `<script type="module"`, bridge+`<script type="module"`, 1))
return
}
http.ServeFile(w, r, filepath.Join(dist, filepath.Clean(r.URL.Path)))
}
func main() {
out := flag.String("out", "screenshots", "directory of the README images")
firefox := flag.String("firefox", envOr("FIREFOX", "firefox"), "Firefox executable")
flag.Parse()
log.SetFlags(0)
src, err := os.ReadFile(sample)
if err != nil {
log.Fatalf("run from the repository root: %v", err)
}
if _, err := os.Stat(filepath.Join(dist, "index.html")); err != nil {
log.Fatal("build the frontend first (make frontend)")
}
browser, err := exec.LookPath(*firefox)
if err != nil {
log.Fatalf("Firefox not found: %v", err)
}
abs, _ := filepath.Abs(sample)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
go http.Serve(ln, &server{src: string(src), path: abs})
tmp, err := os.MkdirTemp("", "plotzilla-screenshots-")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(tmp)
raw := filepath.Join(tmp, "raw")
os.MkdirAll(raw, 0o755)
cmd := exec.Command("node", "tools/screenshots/capture.mjs")
cmd.Env = append(os.Environ(), "URL=http://"+ln.Addr().String()+"/", "OUT="+raw,
"PROFILE="+filepath.Join(tmp, "profile"), "FIREFOX="+browser)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
log.Fatalf("capture: %v", err)
}
shots, _ := filepath.Glob(filepath.Join(raw, "*.png"))
for _, f := range shots {
img, err := readPNG(f)
if err != nil {
log.Fatal(err)
}
if err := writePNG(filepath.Join(*out, filepath.Base(f)), roundCorners(img, 24)); err != nil {
log.Fatal(err)
}
}
if err := pdfOutput(string(src), tmp, filepath.Join(*out, "pdf-output.png")); err != nil {
log.Fatal(err)
}
fmt.Printf("%d screenshots and pdf-output.png written to %s\n", len(shots), *out)
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func readPNG(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return png.Decode(f)
}
func writePNG(path string, img image.Image) error {
var b bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestCompression}
if err := enc.Encode(&b, img); err != nil {
return err
}
return os.WriteFile(path, b.Bytes(), 0o644)
}
// roundCorners makes the corners outside a rounded rectangle transparent:
// the window's 12 px corners, at device scale 2.
func roundCorners(src image.Image, radius float64) *image.NRGBA {
b := src.Bounds()
img := image.NewNRGBA(b)
draw.Draw(img, b, src, b.Min, draw.Src)
w, h := float64(b.Dx()), float64(b.Dy())
for y := 0; y < b.Dy(); y++ {
for x := 0; x < b.Dx(); x++ {
// Distance from the centre of the nearest corner circle.
cx := math.Min(math.Max(float64(x)+0.5, radius), w-radius)
cy := math.Min(math.Max(float64(y)+0.5, radius), h-radius)
d := math.Hypot(float64(x)+0.5-cx, float64(y)+0.5-cy)
if d <= radius-0.5 {
continue
}
i := img.PixOffset(b.Min.X+x, b.Min.Y+y)
img.Pix[i+3] = uint8(float64(img.Pix[i+3]) * math.Max(0, radius+0.5-d))
}
}
return img
}
// pdfOutput renders the title page and the script pages of the sample
// side by side on the app background.
func pdfOutput(src, tmp, path string) error {
doc := fountain.Parse(src)
pages := append([]layout.Page{layout.TitlePage(doc.TitlePage, doc.Settings.Paper)},
layout.Paginate(doc, layout.Options{SceneNumbers: true}).Pages...)
var b bytes.Buffer
if err := pdf.Render(&b, pages, pdf.Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author}); err != nil {
return err
}
file := filepath.Join(tmp, "sample.pdf")
if err := os.WriteFile(file, b.Bytes(), 0o644); err != nil {
return err
}
prefix := filepath.Join(tmp, "page")
if out, err := exec.Command("pdftoppm", "-png", "-r", "110", file, prefix).CombinedOutput(); err != nil {
return fmt.Errorf("pdftoppm (poppler) is needed for pdf-output.png: %v %s", err, out)
}
files, _ := filepath.Glob(prefix + "-*.png")
const margin = 60
var imgs []image.Image
for _, f := range files {
img, err := readPNG(f)
if err != nil {
return err
}
imgs = append(imgs, img)
}
if len(imgs) == 0 {
return fmt.Errorf("pdftoppm produced no page")
}
pw, ph := imgs[0].Bounds().Dx(), imgs[0].Bounds().Dy()
canvas := image.NewNRGBA(image.Rect(0, 0, margin+len(imgs)*(pw+margin), ph+2*margin))
bg := color.NRGBA{7, 12, 24, 255} // --bg
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
for i, img := range imgs {
r := image.Rect(margin+i*(pw+margin), margin, margin+i*(pw+margin)+pw, margin+ph)
shadow(canvas, r, bg)
draw.Draw(canvas, r, img, img.Bounds().Min, draw.Src)
}
return writePNG(path, canvas)
}
// shadow darkens the background around a page, fading over 24 px.
func shadow(img *image.NRGBA, r image.Rectangle, bg color.NRGBA) {
const spread = 24
for y := r.Min.Y - spread; y < r.Max.Y+spread; y++ {
for x := r.Min.X - spread; x < r.Max.X+spread; x++ {
if !(image.Point{x, y}).In(img.Bounds()) || (image.Point{x, y}).In(r) {
continue
}
dx := max(r.Min.X-x, 0, x-r.Max.X+1)
dy := max(r.Min.Y-y, 0, y-r.Max.Y+1)
k := 1 - 0.3*(1-math.Min(math.Hypot(float64(dx), float64(dy))/spread, 1))
img.SetNRGBA(x, y, color.NRGBA{uint8(float64(bg.R) * k), uint8(float64(bg.G) * k), uint8(float64(bg.B) * k), 255})
}
}
}
+353
View File
@@ -0,0 +1,353 @@
{
"name": "plotzilla-screenshots",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plotzilla-screenshots",
"devDependencies": {
"puppeteer-core": "^25.12.0"
}
},
"node_modules/@puppeteer/browsers": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.3.tgz",
"integrity": "sha512-2Bt3m6dDAJqmZehn0wiXSYyQmuhyiHHZ7FjvrVaS1W2RoFGV6eCK71UvKYkNJjkwC1Q9nobjhvTMhLQztFVINA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"modern-tar": "^0.8.4",
"yargs": "^18.0.0"
},
"bin": {
"browsers": "lib/main-cli.js"
},
"engines": {
"node": ">=22.12.0"
},
"peerDependencies": {
"proxy-agent": ">=8.0.1",
"yauzl": "^2.10.0 || ^3.4.0"
},
"peerDependenciesMeta": {
"proxy-agent": {
"optional": true
},
"yauzl": {
"optional": true
}
}
},
"node_modules/ansi-regex": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chromium-bidi": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1687809",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1687809.tgz",
"integrity": "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"dev": true,
"license": "MIT"
},
"node_modules/modern-tar": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/puppeteer-core": {
"version": "25.12.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.12.0.tgz",
"integrity": "sha512-z6LQUt5SH7jwGtGA+nMtJuEjZIsqPiwQ+EFHCYuVDQrJpvifNmmGuyDvTzAKPyFzihxgHc5f3aMzu2kn8Ps37A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.2.3",
"chromium-bidi": "17.0.2",
"devtools-protocol": "0.0.1687809",
"typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.3",
"ws": "^8.21.3"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/string-width": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/typed-query-selector": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"dev": true,
"license": "MIT"
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^8.2.1",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "plotzilla-screenshots",
"private": true,
"description": "Captures the README screenshots (make screenshots)",
"type": "module",
"devDependencies": {
"puppeteer-core": "^25.12.0"
}
}
+92
View File
@@ -0,0 +1,92 @@
Title: THE LAST REEL
Credit: Written by
Author: Jane Doe
Source: Based on a true story
Draft date: September 28, 2026
Contact:
Jane Doe
jane@example.com
+1 555 0100
FADE IN:
INT. PROJECTION BOOTH - NIGHT
Dust hangs in the **beam** of the projector. ELODIE (40s), sleeves rolled up, threads a reel by hand. Old film cans line every shelf.
INSERT - THE FILM CAN
A faded label: _"The Last Reel - 1942"_.
BACK TO SCENE
ELODIE (V.O.)
(quietly)
It always ends *here*. Same frame, same scratch, every single night.
MARC
You say that every night.
ELODIE ^
Because it's true every night.
The film snaps. Below them, the screen floods with white light.
CUT TO:
> PARIS, 1942 <
EXT. CINEMA LE RIALTO - DAY
A crowd queues under a hand-painted marquee. Soldiers watch from across the street.
A YOUNG WOMAN, a younger ELODIE's grandmother, slips a film can into her coat.
YOUNG WOMAN
(to the usher)
Two tickets. The late show.
USHER
There is no late show anymore, mademoiselle. Curfew.
YOUNG WOMAN
Then the last one. Whatever is playing, I want to see how it ends.
The usher hesitates, glances at the soldiers, then tears two tickets.
INT. CINEMA LE RIALTO - AUDITORIUM - CONTINUOUS
Rows of red velvet seats, half empty. The lights dim. On screen, a newsreel flickers.
The young woman finds a seat in the last row and pulls the can from her coat.
MATCH CUT TO:
INT. PROJECTION BOOTH - NIGHT
Elodie holds the same can, its edges rusted, the label almost gone.
ELODIE
Eighty years. And you never told anyone what was on it.
She opens the lid. Inside, a single reel and a folded letter.
INSERT - THE LETTER
Handwritten, in French: _"If you are reading this, run it to the end."_
BACK TO SCENE
Elodie looks at the projector, then at the reel. She makes her choice.
MARC (O.S.)
Elodie? The audience is waiting.
ELODIE
Let them wait. Tonight we show something new.
She laces the old reel into the machine and throws the switch.
> FADE OUT.
> THE END <