feat: first release !
This commit is contained in:
1 parent
77a333e8d9
commit
664527182d
66 files changed
+13812
-16
No files matched your search
@@ -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()
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+353
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "plotzilla-screenshots",
|
||||
"private": true,
|
||||
"description": "Captures the README screenshots (make screenshots)",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"puppeteer-core": "^25.12.0"
|
||||
}
|
||||
}
|
||||
@@ -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 <
|
||||
Reference in new issue
Block a user