diff --git a/.gitignore b/.gitignore index 2104bfb..00322e1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,16 @@ __pycache__/ worker/node_modules/ worker/.wrangler/ .dev.vars + +# Marketing automation +marketing/content/*.json +marketing/media/generated/* +marketing/logs/*.log +marketing/config/settings.json +marketing/.env + +# Post exports (generated packs incl. media copies) +marketing/exports/ + +# Concept mockups (drafts pending validation) +mockups/ diff --git a/404.html b/404.html index 349770a..df10a6b 100644 --- a/404.html +++ b/404.html @@ -3,13 +3,17 @@ - + Not found · PLOTFLOW* diff --git a/README.md b/README.md index eba3ffa..256a1cf 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # PLOTFLOW -**Independent pen-plotter studio.** Universal-Century mobile suits, rebuilt as a single unbroken line and drawn in ink by machine. +**Independent pen-plotter studio.** Universal-Century mobile suits, redrawn as precise vector line work and drawn in ink by machine. -一筆書き — *hitofudegaki*, "one continuous line." +マシンドロー — *machine-draw*: drawn by machine, never printed. 🌐 **[plotflow.io](https://plotflow.io)** @@ -10,20 +10,20 @@ ## What it is -PlotFlow reimagines each mobile suit as one continuous vector path — no pen lifts, no breaks — and traces it in technical pen on archival paper with an AxiDraw plotter. Nothing is printed. Every impression is *drawn*, so each carries the minor variation of the medium; editions are limited, made to order, signed, and numbered. +PlotFlow rebuilds each mobile suit as precise vector line work and traces it in technical pen on archival paper with an AxiDraw plotter. Nothing is printed. Every impression is *drawn*, so each carries the minor variation of the medium; editions are limited, made to order, signed, and numbered. This repository is the PlotFlow homepage — a no-build static site (plain HTML/CSS/JS, no framework, no bundler) hosted on GitHub Pages. Its centerpiece is an interactive **Live Plot** that animates a suit being drawn line-by-line by a virtual plotter, alongside the shop of current editions. ## The work - **Drawn, not printed** — technical pen on 300gsm archival stock, never a print -- **One continuous line** — each suit is a single unbroken path +- **Drawn by machine** — an AxiDraw traces every stroke, pen up and pen down - **Limited editions** — made to order, signed and numbered - Variation in line and ink is a feature of the medium, not a flaw ## Process -1. **Vectorize** (ベクター化) — the suit is rebuilt as one continuous vector path +1. **Vectorize** (ベクター化) — the suit is rebuilt as clean vector line work 2. **Plot** (作画) — an AxiDraw traces it in ink; a single A1 can run for hours 3. **Finish** (仕上げ) — inspected, signed, and numbered @@ -63,4 +63,4 @@ git add -A && git commit -m "update site" && git push --- -© 2026 PLOTFLOW* — plotted in the U.S.A. · 一筆書き · one continuous line +© 2026 PLOTFLOW* — plotted in the U.S.A. · マシンドロー · drawn by machine diff --git a/about.html b/about.html index 5d0ff4c..d9bc2cd 100644 --- a/about.html +++ b/about.html @@ -3,13 +3,17 @@ - + About · PLOTFLOW* diff --git a/assets/posts/post-bigzam-drop.png b/assets/posts/post-bigzam-drop.png new file mode 100644 index 0000000..19dcfd8 Binary files /dev/null and b/assets/posts/post-bigzam-drop.png differ diff --git a/assets/posts/post-bigzam-lore.png b/assets/posts/post-bigzam-lore.png index 8735492..e9b80dc 100644 Binary files a/assets/posts/post-bigzam-lore.png and b/assets/posts/post-bigzam-lore.png differ diff --git a/assets/posts/post-bigzam-process.png b/assets/posts/post-bigzam-process.png new file mode 100644 index 0000000..cf56ffe Binary files /dev/null and b/assets/posts/post-bigzam-process.png differ diff --git a/assets/posts/post-bigzam-spec.png b/assets/posts/post-bigzam-spec.png new file mode 100644 index 0000000..88331c0 Binary files /dev/null and b/assets/posts/post-bigzam-spec.png differ diff --git a/assets/posts/post-brand-dark.png b/assets/posts/post-brand-dark.png index 4427f74..a9241d0 100644 Binary files a/assets/posts/post-brand-dark.png and b/assets/posts/post-brand-dark.png differ diff --git a/assets/posts/post-brand-light.png b/assets/posts/post-brand-light.png index 05b0779..4f03041 100644 Binary files a/assets/posts/post-brand-light.png and b/assets/posts/post-brand-light.png differ diff --git a/assets/posts/post-dom-drop.png b/assets/posts/post-dom-drop.png new file mode 100644 index 0000000..40d67a8 Binary files /dev/null and b/assets/posts/post-dom-drop.png differ diff --git a/assets/posts/post-dom-lore.png b/assets/posts/post-dom-lore.png index 8e7618b..35248f2 100644 Binary files a/assets/posts/post-dom-lore.png and b/assets/posts/post-dom-lore.png differ diff --git a/assets/posts/post-dom-process.png b/assets/posts/post-dom-process.png new file mode 100644 index 0000000..7d27686 Binary files /dev/null and b/assets/posts/post-dom-process.png differ diff --git a/assets/posts/post-dom-spec.png b/assets/posts/post-dom-spec.png new file mode 100644 index 0000000..9357763 Binary files /dev/null and b/assets/posts/post-dom-spec.png differ diff --git a/assets/posts/post-gp02-drop.png b/assets/posts/post-gp02-drop.png new file mode 100644 index 0000000..4c488bd Binary files /dev/null and b/assets/posts/post-gp02-drop.png differ diff --git a/assets/posts/post-gp02-lore.png b/assets/posts/post-gp02-lore.png index 2840d46..273c7fe 100644 Binary files a/assets/posts/post-gp02-lore.png and b/assets/posts/post-gp02-lore.png differ diff --git a/assets/posts/post-gp02-process.png b/assets/posts/post-gp02-process.png new file mode 100644 index 0000000..be9a822 Binary files /dev/null and b/assets/posts/post-gp02-process.png differ diff --git a/assets/posts/post-gp02-spec.png b/assets/posts/post-gp02-spec.png new file mode 100644 index 0000000..d74ba90 Binary files /dev/null and b/assets/posts/post-gp02-spec.png differ diff --git a/assets/posts/post-guncannon-drop.png b/assets/posts/post-guncannon-drop.png new file mode 100644 index 0000000..b22e7c5 Binary files /dev/null and b/assets/posts/post-guncannon-drop.png differ diff --git a/assets/posts/post-guncannon-lore.png b/assets/posts/post-guncannon-lore.png index de79cb2..a540622 100644 Binary files a/assets/posts/post-guncannon-lore.png and b/assets/posts/post-guncannon-lore.png differ diff --git a/assets/posts/post-guncannon-process.png b/assets/posts/post-guncannon-process.png new file mode 100644 index 0000000..7ff46a9 Binary files /dev/null and b/assets/posts/post-guncannon-process.png differ diff --git a/assets/posts/post-guncannon-spec.png b/assets/posts/post-guncannon-spec.png new file mode 100644 index 0000000..dfc1fd5 Binary files /dev/null and b/assets/posts/post-guncannon-spec.png differ diff --git a/assets/posts/post-zaku-drop.png b/assets/posts/post-zaku-drop.png new file mode 100644 index 0000000..2763c9c Binary files /dev/null and b/assets/posts/post-zaku-drop.png differ diff --git a/assets/posts/post-zaku-lore.png b/assets/posts/post-zaku-lore.png index 6cb5c0e..6177764 100644 Binary files a/assets/posts/post-zaku-lore.png and b/assets/posts/post-zaku-lore.png differ diff --git a/assets/posts/post-zaku-process.png b/assets/posts/post-zaku-process.png new file mode 100644 index 0000000..36a86ab Binary files /dev/null and b/assets/posts/post-zaku-process.png differ diff --git a/assets/posts/post-zaku-spec.png b/assets/posts/post-zaku-spec.png new file mode 100644 index 0000000..d7e8953 Binary files /dev/null and b/assets/posts/post-zaku-spec.png differ diff --git a/assets/posts/post-zgok-drop.png b/assets/posts/post-zgok-drop.png new file mode 100644 index 0000000..d15eca3 Binary files /dev/null and b/assets/posts/post-zgok-drop.png differ diff --git a/assets/posts/post-zgok-lore.png b/assets/posts/post-zgok-lore.png index 926381d..1f181a3 100644 Binary files a/assets/posts/post-zgok-lore.png and b/assets/posts/post-zgok-lore.png differ diff --git a/assets/posts/post-zgok-process.png b/assets/posts/post-zgok-process.png new file mode 100644 index 0000000..907b672 Binary files /dev/null and b/assets/posts/post-zgok-process.png differ diff --git a/assets/posts/post-zgok-spec.png b/assets/posts/post-zgok-spec.png new file mode 100644 index 0000000..727c544 Binary files /dev/null and b/assets/posts/post-zgok-spec.png differ diff --git a/assets/social/bigzam_process.mp4 b/assets/social/bigzam_process.mp4 new file mode 100644 index 0000000..140a54f Binary files /dev/null and b/assets/social/bigzam_process.mp4 differ diff --git a/assets/social/bigzam_showcase.png b/assets/social/bigzam_showcase.png new file mode 100644 index 0000000..8907768 Binary files /dev/null and b/assets/social/bigzam_showcase.png differ diff --git a/assets/social/dom_process.mp4 b/assets/social/dom_process.mp4 new file mode 100644 index 0000000..78aa5d5 Binary files /dev/null and b/assets/social/dom_process.mp4 differ diff --git a/assets/social/dom_showcase.png b/assets/social/dom_showcase.png new file mode 100644 index 0000000..5b37ce6 Binary files /dev/null and b/assets/social/dom_showcase.png differ diff --git a/assets/social/gp02_process.mp4 b/assets/social/gp02_process.mp4 new file mode 100644 index 0000000..68694fd Binary files /dev/null and b/assets/social/gp02_process.mp4 differ diff --git a/assets/social/gp02_showcase.png b/assets/social/gp02_showcase.png new file mode 100644 index 0000000..55b91a7 Binary files /dev/null and b/assets/social/gp02_showcase.png differ diff --git a/assets/social/guncannon_process.mp4 b/assets/social/guncannon_process.mp4 new file mode 100644 index 0000000..a7de372 Binary files /dev/null and b/assets/social/guncannon_process.mp4 differ diff --git a/assets/social/guncannon_showcase.png b/assets/social/guncannon_showcase.png new file mode 100644 index 0000000..e96a1b0 Binary files /dev/null and b/assets/social/guncannon_showcase.png differ diff --git a/assets/social/zaku_process.mp4 b/assets/social/zaku_process.mp4 new file mode 100644 index 0000000..2f29a85 Binary files /dev/null and b/assets/social/zaku_process.mp4 differ diff --git a/assets/social/zaku_showcase.png b/assets/social/zaku_showcase.png new file mode 100644 index 0000000..ba418e8 Binary files /dev/null and b/assets/social/zaku_showcase.png differ diff --git a/assets/social/zgok_process.mp4 b/assets/social/zgok_process.mp4 new file mode 100644 index 0000000..aebc70a Binary files /dev/null and b/assets/social/zgok_process.mp4 differ diff --git a/assets/social/zgok_showcase.png b/assets/social/zgok_showcase.png new file mode 100644 index 0000000..63517be Binary files /dev/null and b/assets/social/zgok_showcase.png differ diff --git a/commissions.html b/commissions.html index 6ffeb8d..112b9a7 100644 --- a/commissions.html +++ b/commissions.html @@ -3,13 +3,17 @@ - + Commissions · PLOTFLOW* diff --git a/contact.html b/contact.html index 8823df5..d2eb780 100644 --- a/contact.html +++ b/contact.html @@ -3,13 +3,17 @@ - + Contact · PLOTFLOW* diff --git a/data/editions.js b/data/editions.js index 5dd455d..dc0d056 100644 --- a/data/editions.js +++ b/data/editions.js @@ -7,7 +7,7 @@ window.PLOTFLOW = { "jp": "ザク", "edition": "ED. 09/25 · 11×14″", "price": "$45", -"lore": "The workhorse of the Principality of Zeon. Mass-produced and instantly known by its single mono-eye and shoulder spike. More Zaku IIs saw combat in the One Year War than any other mobile suit.", +"lore": "The workhorse of the Principality of Zeon — the suit that proved the mobile-suit weapon class in combat. Instantly known by its single mono-eye and shoulder spike, more Zaku IIs fought in the One Year War (U.C. 0079) than any other machine. Char Aznable's crimson command type moved at three times the normal speed, earning him the name Red Comet.", "file": "zaku-ii.svg", "w": 1493, "h": 801, @@ -19,7 +19,7 @@ window.PLOTFLOW = { "jp": "ガンキャノン", "edition": "ED. 18/25 · 11×14″", "price": "$45", -"lore": "A mid-range support unit of the Earth Federation, built around its shoulder-mounted cannons. Heavier armor traded mobility for firepower fighting alongside the RX-78.", +"lore": "One of the Earth Federation's first mass-production mobile suits, developed under Project V alongside the RX-78 Gundam. Built around twin shoulder cannons for mid- and long-range fire support, it traded mobility for heavier armor and served aboard White Base through the One Year War.", "file": "guncannon.svg", "w": 1718, "h": 882, @@ -31,7 +31,7 @@ window.PLOTFLOW = { "jp": "ビグ・ザム", "edition": "ED. 05/25 · 11×14″", "price": "$45", -"lore": "A colossal Zeon mobile armor designed for base defense, shielded by an I-field that turned aside beam weapons. A single unit was meant to hold off an entire fleet.", +"lore": "A colossal Zeon mobile armor built for base defense, its I-field barrier turning aside beam weapons while banks of mega particle guns swept the field. A single unit was meant to hold off an entire fleet. Dozle Zabi took it into the defense of Solomon in the war's final days.", "file": "big-zam.svg", "w": 1427, "h": 817, @@ -43,7 +43,7 @@ window.PLOTFLOW = { "jp": "ドム", "edition": "ED. 12/25 · 11×14″", "price": "$45", -"lore": "A ground-assault suit riding hover thrusters for deceptively fast movement across open terrain. Its scattering beam gun and giant heat saber made it a frontline terror.", +"lore": "A Zeon ground-assault suit that skated across open terrain on thermonuclear hover jets — deceptively fast for its bulk. Armed with a scattering beam gun and a giant heat saber, its Rick Dom variants armed the Black Tri-Stars and their feared Jet Stream Attack.", "file": "dom.svg", "w": 1135, "h": 749, @@ -55,7 +55,7 @@ window.PLOTFLOW = { "jp": "ズゴック", "edition": "ED. 22/25 · 11×14″", "price": "$45", -"lore": "An amphibious Zeon suit built for hit-and-run raids from the water. Rounded armor, clawed manipulators, and head-mounted mega particle guns.", +"lore": "An amphibious Zeon suit built for hit-and-run raids from the water. Rounded armor, iron-nail claws, and head-mounted mega particle guns made it deadly at close range. Char Aznable fielded a custom command type in his signature red.", "file": "zgok.svg", "w": 1135, "h": 749, @@ -67,7 +67,7 @@ window.PLOTFLOW = { "jp": "ガンダム試作2号機", "edition": "ED. 15/25 · 11×14″", "price": "$45", -"lore": "A Gundam Development Project unit engineered to carry and fire a tactical warhead. Heavy shielding and a massive plasma-clad shield define its silhouette.", +"lore": "A Gundam Development Project prototype engineered around a single purpose: to carry and fire a tactical nuclear warhead. Heavy shielding and a massive plasma-clad shield define its silhouette. In U.C. 0083 it was stolen by Anavel Gato — the Nightmare of Solomon.", "file": "gp-02.svg", "w": 1718, "h": 882, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index a491b7c..d041648 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1,6 +1,6 @@ # PLOTFLOW — Design System -A small studio selling **pen-plotter (AxiDraw) line drawings of Universal-Century mobile suits** — each rebuilt as a single continuous line and drawn in red ink on archival paper, signed + numbered. *Plotted, not printed.* est. 2026. +A small studio selling **pen-plotter (AxiDraw) line drawings of Universal-Century mobile suits** — each redrawn as vector line work and drawn in red ink on archival paper, signed + numbered. *Plotted, not printed.* est. 2026. Aesthetic: **washed/muted ground · bold white grotesque · Japanese · halftone + asterisk**, with an interactive **Live Plot** hero that draws the suit in front of you. This document is the source of truth for tokens, components, and rules. (`tokens.json` is the machine-readable mirror; `tokens.css` is what the site consumes.) @@ -12,7 +12,7 @@ Aesthetic: **washed/muted ground · bold white grotesque · Japanese · halftone |---|---| | **Name / wordmark** | `PLOTFLOW*` — always all-caps, **one color** (the `*` is the only red). Katakana: プロットフロー | | **Tagline** | *Drawn, not printed.* | -| **Motto (JP)** | 一筆書き (*ichi-fude-gaki*, "single-stroke drawing") · "One continuous line" | +| **Motto (JP)** | マシンドロー (*machine-draw*) · "Drawn by machine" | | **Marks** | est. 2026 · plotted in the U.S.A. · signed + numbered | | **Voice** | Confident, technical, a little cryptic. Machine-precise meets hand-made-scarce. Never corporate, never cute. | @@ -68,7 +68,7 @@ The centerpiece. Muted ground (`linear-gradient` + `radial-gradient`) + `.grain` - `.hero-head` — `h1` display wordmark + `//`-style `.sub`. - `.stage-area > .bed` — the **paper sheet**; holds `#stage` (the SVG: `#ppath` + `#pen`) and the `.replay` overlay. **The bed must have a definite width** (`width:min(62%,640px)`) or it collapses. - `.hud` — live readouts (progress, ink-m, plot-time, position), top-right. -- `.hero-jp` / `.hero-collab` / `.ast-bug` — the lower-left 一筆書き lockup, `[ LIVE PLOT* ]`, floating asterisk. +- `.hero-jp` / `.hero-collab` / `.ast-bug` — the lower-left マシンドロー lockup, `[ LIVE PLOT* ]`, floating asterisk. - `.pbar` + `.dock` — progress bar + controls (suit `select`, play/pause, restart, skip, speed segmented control). ### Manifesto `.mani` diff --git a/faq.html b/faq.html index 633f009..e5c4307 100644 --- a/faq.html +++ b/faq.html @@ -3,13 +3,17 @@ - + FAQ · PLOTFLOW* diff --git a/index.html b/index.html index d2b093e..1a58bd1 100644 --- a/index.html +++ b/index.html @@ -3,13 +3,17 @@ - + PLOTFLOW · マシンドロー @@ -89,6 +93,7 @@

PLOTFLOW*

+ diff --git a/marketing/.env.example b/marketing/.env.example new file mode 100644 index 0000000..5d5c6d9 --- /dev/null +++ b/marketing/.env.example @@ -0,0 +1,11 @@ +# PlotFlow Marketing Automation Environment Variables + +# Anthropic API (for AI content generation) +ANTHROPIC_API_KEY=sk-ant-api03-... + +# Instagram API credentials (alternatively set in config/settings.json) +INSTAGRAM_ACCESS_TOKEN=IGQWRPxxx... +INSTAGRAM_ACCOUNT_ID=17841... + +# Optional: Webhook endpoints for notifications +WEBHOOK_URL=https://your-webhook-url.com/plotflow-posts diff --git a/marketing/README.md b/marketing/README.md new file mode 100644 index 0000000..69b0bdb --- /dev/null +++ b/marketing/README.md @@ -0,0 +1,339 @@ +# PlotFlow Marketing Automation + +Automated Instagram content generation and posting system for PlotFlow artwork. + +## 🎯 Features + +- **AI-Powered Content Generation**: Creates engaging captions with Claude AI +- **Multi-Variant Captions**: Generates 3 different caption options per post +- **Smart Scheduling**: Distributes posts across optimal times +- **Full Instagram API Integration**: Automated posting of images, videos, and carousels +- **Post Type Variety**: Showcases, process videos, and behind-the-scenes content +- **Bilingual**: English and Japanese (マシンドロー) content +- **Queue Management**: Track posted, pending, and failed posts + +## 📋 Prerequisites + +1. **Instagram Business Account** +2. **Facebook Developer Account** +3. **Instagram Access Token** (see setup below) +4. **Anthropic API Key** (for AI content generation) + +## 🚀 Setup + +### 1. Install Dependencies + +```bash +cd marketing +pip install -r requirements.txt +``` + +### 2. Create Your Settings File + +The real `settings.json` is gitignored (it holds your tokens). Copy the template: + +```bash +cp config/settings.example.json config/settings.json +``` + +Then fill in your credentials in `config/settings.json`. + +### 3. Configure Instagram API + +1. Go to [Facebook for Developers](https://developers.facebook.com/) +2. Create a new app +3. Add **Instagram Graph API** product +4. Connect your Instagram Business account +5. Generate a long-lived access token +6. Update `config/settings.json`: + +```json +{ + "instagram": { + "access_token": "YOUR_ACCESS_TOKEN_HERE", + "account_id": "YOUR_INSTAGRAM_BUSINESS_ACCOUNT_ID" + } +} +``` + +**Getting your account ID:** +```bash +curl -X GET "https://graph.facebook.com/v21.0/me/accounts?access_token=YOUR_TOKEN" +``` + +### 4. Set Anthropic API Key + +```bash +export ANTHROPIC_API_KEY="your_api_key_here" +``` + +Or add to `.env` file: +``` +ANTHROPIC_API_KEY=sk-ant-... +``` + +### 5. Test Connection + +```bash +python instagram_api.py # Test Instagram API +python content_generator.py # Generate sample content +``` + +## 🎬 Media Generation + +Instagram needs publicly-hosted media. The media generator renders each +edition's continuous-line path into two Instagram-ready assets — no browser, +no system ffmpeg (a static binary ships with `imageio-ffmpeg`): + +- **`{key}_process.mp4`** — 1080×1080 H.264 video of the suit drawn line-by-line + in red ink on Bristol paper, with the moving pen head and bed label. Used for + *process* posts. +- **`{key}_showcase.png`** — 1080×1350 still of the finished plot, composed as a + feed image with code / edition / name / 日本語 / price. Used for everything else. + +### Build the media + manifest + +```bash +python media_generator.py build --publish-dir ../assets/social +``` + +This renders all six editions, writes `media/generated/manifest.json` (the +map the poster reads), and copies the files into `assets/social/`. Commit that +folder so GitHub Pages serves them at `https://plotflow.io/assets/social/…`, +which is what the Instagram API fetches. The public URL base is set in +`config/settings.json` under `site`. + +You only need to rebuild when editions change. Other handy forms: + +```bash +python media_generator.py list # list editions +python media_generator.py still --key zaku # one still +python media_generator.py video --key zaku --duration 20 +python automate.py media # build all + publish (via automate) +``` + +## 📤 Manual posting (no Meta API) + +If the Meta API is unavailable — a disabled/appealed Facebook account, no +approved app, or you just want to post by hand — use the exporter. It turns the +editorial card library into ready-to-post packs plus a browser review board, so +you publish through the Instagram app in seconds. No API, no approval, no +account dependency. + +```bash +python tools/gen_posts.py # (run from repo root) build the 26 cards +python automate.py export # write packs + grouped review board +``` + +`export` bundles the card library (`assets/posts/`): the 5 styles per suit +(process, lore, spec, drop) plus the two brand cards, each paired with its +canon caption from `tools/captions.json`. It creates `exports//`: + +- one folder per card with the **card image** + **caption.txt** +- **index.html** — a review board grouped by suit, each card with a **Copy + caption** button + +(Use `python automate.py export` → falls back to `--queue` for the older +still+video scheduler set if you ever need it: `python exporter.py --queue`.) + +Open `index.html`, and for each card: click *Copy caption*, save the image, and +post it in the Instagram app. Fastest on your phone — AirDrop/sync the folder, +or open the board on mobile. + +## 📖 Usage (Meta API path) + +### Generate Content (30 days worth) + +```bash +python automate.py generate --days 30 +``` + +This creates 90 posts (3 per day) with AI-generated captions. + +### Generate & Schedule + +```bash +python automate.py schedule --days 30 +``` + +Creates content and adds to posting queue with optimal timing. + +### Post Pending Content + +```bash +python automate.py post +``` + +Posts everything due now. Run this via cron for full automation. + +### Check Status + +```bash +python automate.py status +``` + +Shows queue stats and next scheduled post. + +### Full Workflow + +```bash +python automate.py full --days 30 +``` + +Generate, schedule, and post pending in one command. + +### Dry Run Mode + +Test without actually posting: + +```bash +python automate.py post --dry-run +``` + +## 🤖 Automation (Cron) + +Add to crontab for automatic posting: + +```bash +# Post pending content every hour +0 * * * * cd /path/to/plotflow.github.io/marketing && python automate.py post >> logs/cron.log 2>&1 + +# Generate new content weekly +0 9 * * 1 cd /path/to/plotflow.github.io/marketing && python automate.py schedule --days 7 >> logs/cron.log 2>&1 +``` + +## 📁 File Structure + +``` +marketing/ +├── automate.py # Main automation script +├── content_generator.py # AI content generation +├── instagram_api.py # Instagram Graph API wrapper +├── scheduler.py # Post scheduling logic +├── requirements.txt # Python dependencies +├── config/ +│ └── settings.json # Configuration (API keys, schedule, hashtags) +├── content/ +│ ├── batch_*.json # Generated content batches +│ ├── post_queue.json # Scheduled posts queue +│ └── post_history.json # Posted content history +├── media/ +│ └── generated/ # Generated images/videos +└── logs/ + └── cron.log # Automation logs +``` + +## 🎨 Content Types + +**Showcase (50%)**: Finished artwork photos +- Highlights the completed piece +- Technical details and pricing +- High-quality product shots + +**Process (30%)**: Timelapse videos +- Shows the plotter in action +- Stroke by stroke, drawn in real time +- Mesmerizing machine art + +**Behind-the-Scenes (20%)**: Studio content +- Materials and tools +- Setup shots +- Process insights + +## 🎯 Posting Strategy + +- **3 posts per day** at optimal times (9am, 1pm, 5pm, 8pm) +- **Minimum 3 hours** between posts +- **7 days a week** coverage +- **Smart hashtags**: 20 relevant tags per post +- **Bilingual content**: English + Japanese (マシンドロー) + +## 🔧 Customization + +### Adjust Posting Schedule + +Edit `config/settings.json`: + +```json +{ + "posting": { + "schedule": { + "times": ["09:00", "13:00", "17:00", "20:00"], + "timezone": "America/New_York", + "days": ["monday", "tuesday", ...] + }, + "frequency": { + "daily_limit": 3, + "min_hours_between": 3 + } + } +} +``` + +### Customize Hashtags + +```json +{ + "content": { + "hashtags": { + "core": ["#plotflow", "マシンドロー", "#penplotter"], + "art": ["#gundam", "#mobilesuit", ...], + "process": ["#plotter", "#axidraw", ...], + "product": ["#limitedEdition", ...] + } + } +} +``` + +### AI Generation Style + +```json +{ + "ai": { + "tone": "artistic, technical, minimalist", + "style": "Blend Japanese aesthetics with technical precision..." + } +} +``` + +## 📊 Monitoring + +Check `content/post_history.json` for analytics: +- Posted vs. failed rate +- Best performing post types +- Engagement patterns (requires manual tracking) + +## ⚠️ Important Notes + +1. **Media URLs**: Posts require publicly accessible image/video URLs +2. **Rate Limits**: Instagram has posting limits (check API docs) +3. **Content Review**: Always review AI-generated content before posting +4. **Backup Queue**: Keep backups of `post_queue.json` +5. **Token Expiry**: Instagram tokens expire; renew regularly + +## 🆘 Troubleshooting + +**"Access token invalid"** +- Regenerate token in Facebook Developer Console +- Ensure Instagram account is Business type +- Check account_id is correct + +**"Media not found"** +- Media URLs must be publicly accessible +- Use HTTPS, not HTTP +- Test URL in browser first + +**"No posts generated"** +- Check ANTHROPIC_API_KEY is set +- Verify `data/editions.js` exists +- Run content_generator.py standalone + +## 📚 Resources + +- [Instagram Graph API Docs](https://developers.facebook.com/docs/instagram-api) +- [Instagram Content Publishing](https://developers.facebook.com/docs/instagram-api/guides/content-publishing) +- [Access Token Guide](https://developers.facebook.com/docs/instagram-basic-display-api/overview#instagram-user-access-tokens) + +--- + +**Built for PlotFlow** · マシンドロー · Machine-drawn art automation diff --git a/marketing/automate.py b/marketing/automate.py new file mode 100755 index 0000000..fab13b1 --- /dev/null +++ b/marketing/automate.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +PlotFlow Marketing Automation +Main automation script that generates content, schedules, and posts to Instagram. +""" + +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path +import argparse + +# Local imports +from content_generator import ContentGenerator +from instagram_api import InstagramAPI +from scheduler import PostScheduler +from media_generator import MediaLibrary + +SCRIPT_DIR = Path(__file__).parent + + +class MarketingAutomation: + """Main automation controller.""" + + def __init__(self, dry_run: bool = False): + self.dry_run = dry_run + self.generator = ContentGenerator() + self.api = InstagramAPI() + self.scheduler = PostScheduler() + self.media = MediaLibrary() + + print("🤖 PlotFlow Marketing Automation") + print("=" * 60) + if self.dry_run: + print("⚠️ DRY RUN MODE - No actual posting will occur\n") + + def generate_content(self, days: int = 30): + """Generate content for specified number of days.""" + posts_per_day = 3 # From settings + total_posts = days * posts_per_day + + print(f"\n📝 Generating {total_posts} posts ({days} days × {posts_per_day}/day)...") + batch = self.generator.generate_content_batch(count=total_posts) + + # Save batch + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"batch_{timestamp}.json" + output_path = self.generator.save_content_batch(batch, filename) + + print(f"✓ Content saved to {output_path}") + return batch + + def schedule_content(self, content_batch, start_date=None): + """Schedule content batch for posting.""" + print(f"\n📅 Scheduling {len(content_batch)} posts...") + + if start_date is None: + start_date = datetime.now(self.scheduler.timezone) + timedelta(days=1) + + scheduled = self.scheduler.generate_schedule(content_batch, start_date) + self.scheduler.add_to_queue(scheduled) + + # Show schedule preview + print(f"\n📋 Schedule Preview:") + for i, post in enumerate(scheduled[:5]): + sched_time = datetime.fromisoformat(post['scheduled_at']) + print(f" {i+1}. {sched_time.strftime('%b %d, %H:%M')} - {post['edition']} ({post['post_type']})") + + if len(scheduled) > 5: + print(f" ... and {len(scheduled) - 5} more") + + return scheduled + + def post_pending(self): + """Post all pending content.""" + pending = self.scheduler.get_pending_posts() + + if not pending: + print("\n📭 No posts due at this time") + return + + print(f"\n📤 {len(pending)} posts ready to publish") + + if self.dry_run: + print("\n🔍 DRY RUN - Would post:") + for post in pending: + key = post.get('edition_key', '') + media_url = self.media.url_for(key, post['media_needed']) + status = media_url if media_url else "⚠️ NO MEDIA (run media_generator build)" + print(f" - {post['edition']} ({post['post_type']}, {post['media_needed']})") + print(f" media: {status}") + return + + # Post each pending item + posted_count = 0 + failed_count = 0 + + for post in pending: + try: + print(f"\n📢 Posting {post['edition']} ({post['media_needed']})...") + + # Select best caption variation (first one for now) + caption = post['captions'][0] + + # Resolve the hosted media URL from the media manifest. + key = post.get('edition_key', '') + media_url = self.media.url_for(key, post['media_needed']) + if not media_url: + raise RuntimeError( + f"No media for '{key}'. Run: python media_generator.py " + f"build --publish-dir ../assets/social (and commit it)." + ) + + if post['media_needed'] == 'video': + post_id = self.api.post_video(media_url, caption) + else: + post_id = self.api.post_image(media_url, caption) + + # Mark as posted + self.scheduler.mark_posted(post['id'], post_id) + posted_count += 1 + + print(f"✓ Posted successfully: {post_id}") + + except Exception as e: + print(f"❌ Failed to post {post['edition']}: {e}") + self.scheduler.mark_failed(post['id'], str(e)) + failed_count += 1 + + print(f"\n📊 Results: {posted_count} posted, {failed_count} failed") + + def show_status(self): + """Display current automation status.""" + stats = self.scheduler.get_stats() + + print(f"\n📊 Current Status:") + print(f" Posts in queue: {stats['total_queued']}") + print(f" Posts published: {stats['total_posted']}") + print(f" Failed posts: {stats['total_failed']}") + + if stats['next_post']: + next_time = datetime.fromisoformat(stats['next_post']) + now = datetime.fromisoformat(stats['current_time']) + time_until = next_time - now + + print(f"\n⏰ Next Scheduled Post:") + print(f" Edition: {stats['next_post_edition']}") + print(f" Time: {next_time.strftime('%Y-%m-%d %H:%M %Z')}") + print(f" In: {self._format_timedelta(time_until)}") + + # Check pending + pending = self.scheduler.get_pending_posts() + if pending: + print(f"\n🔔 {len(pending)} posts are ready to publish now!") + + def _format_timedelta(self, td): + """Format timedelta in human-readable format.""" + hours = td.total_seconds() // 3600 + minutes = (td.total_seconds() % 3600) // 60 + + if hours >= 24: + days = hours // 24 + hours = hours % 24 + return f"{int(days)} days, {int(hours)} hours" + elif hours > 0: + return f"{int(hours)} hours, {int(minutes)} minutes" + else: + return f"{int(minutes)} minutes" + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description='PlotFlow Instagram Marketing Automation') + parser.add_argument('command', choices=['media', 'generate', 'schedule', 'export', 'post', 'status', 'full'], + help='Command to run') + parser.add_argument('--days', type=int, default=30, + help='Number of days of content to generate (default: 30)') + parser.add_argument('--publish-dir', default='../assets/social', + help='where media build copies files for public hosting (default: ../assets/social)') + parser.add_argument('--dry-run', action='store_true', + help='Run without actually posting to Instagram') + + args = parser.parse_args() + + automation = MarketingAutomation(dry_run=args.dry_run) + + if args.command == 'media': + # Render all edition media + manifest (build once, refresh when editions change) + from media_generator import PlotRenderer + renderer = PlotRenderer() + publish = (SCRIPT_DIR / args.publish_dir).resolve() + renderer.build_all(publish_dir=publish) + + elif args.command == 'generate': + # Generate content only + automation.generate_content(days=args.days) + + elif args.command == 'schedule': + # Generate and schedule content + batch = automation.generate_content(days=args.days) + automation.schedule_content(batch) + + elif args.command == 'export': + # Export the editorial card library as ready-to-post packs (no Meta API) + from exporter import export_cards + export_cards() + + elif args.command == 'post': + # Post pending content + automation.post_pending() + + elif args.command == 'status': + # Show status + automation.show_status() + + elif args.command == 'full': + # Full workflow: generate, schedule, and post pending + batch = automation.generate_content(days=args.days) + automation.schedule_content(batch) + automation.post_pending() + + print("\n✨ Complete!") + + +if __name__ == '__main__': + main() diff --git a/marketing/config/settings.example.json b/marketing/config/settings.example.json new file mode 100644 index 0000000..e87c94a --- /dev/null +++ b/marketing/config/settings.example.json @@ -0,0 +1,58 @@ +{ + "instagram": { + "access_token": "YOUR_INSTAGRAM_ACCESS_TOKEN", + "account_id": "YOUR_INSTAGRAM_BUSINESS_ACCOUNT_ID", + "api_version": "v21.0", + "graph_host": "graph.instagram.com", + "_graph_host_note": "graph.instagram.com = 'Instagram API with Instagram login' (no Facebook Page needed; simplest for posting to your own account). Use graph.facebook.com only if you set the app up with Facebook login + a linked Page." + }, + "posting": { + "schedule": { + "enabled": true, + "times": ["09:00", "13:00", "17:00", "20:00"], + "timezone": "America/New_York", + "days": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + }, + "frequency": { + "daily_limit": 3, + "min_hours_between": 3 + } + }, + "content": { + "hashtags": { + "core": ["#plotflow", "マシンドロー", "#penplotter", "#axiDraw", "#machineArt"], + "art": ["#gundam", "#mobilesuit", "#universalCentury", "#lineart", "#continuousLine"], + "process": ["#plotter", "#penAndInk", "#generativeArt", "#roboticArt", "#axidrawart"], + "product": ["#limitedEdition", "#artPrint", "#originalArt", "#artCollector", "#contemporaryArt"] + }, + "max_hashtags": 20, + "caption_variants": 3, + "languages": ["en", "jp"] + }, + "media": { + "formats": { + "image": { + "square": [1080, 1080], + "portrait": [1080, 1350], + "landscape": [1080, 608] + }, + "video": { + "fps": 30, + "duration_range": [15, 60], + "square": [1080, 1080] + } + }, + "export_path": "./marketing/media/generated" + }, + "ai": { + "enabled": true, + "model": "claude-sonnet-4", + "tone": "artistic, technical, minimalist", + "style": "Blend Japanese aesthetics with technical precision. Emphasize craftsmanship, process, and the unique nature of machine-drawn art." + }, + "site": { + "url": "https://plotflow.io", + "social_path": "assets/social", + "_comment": "Media is served from {url}/{social_path}. Run: python media_generator.py build --publish-dir ../assets/social, then commit assets/social so GitHub Pages hosts the files publicly for the Instagram API." + } +} diff --git a/marketing/content_generator.py b/marketing/content_generator.py new file mode 100755 index 0000000..89740bb --- /dev/null +++ b/marketing/content_generator.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +PLOTFLOW Instagram Content Generator +Generates engaging social media content from edition data with AI-powered variations. +""" + +import json +import os +import random +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Dict, Any + +try: + import anthropic +except ImportError: + anthropic = None # AI captions disabled; template fallback still works + +# Load settings +SCRIPT_DIR = Path(__file__).parent +with open(SCRIPT_DIR / 'config/settings.json') as f: + SETTINGS = json.load(f) + +# Edition data path (relative to repo root) +EDITIONS_PATH = SCRIPT_DIR.parent / 'data/editions.js' + + +class ContentGenerator: + """Generates Instagram post content from PlotFlow editions.""" + + def __init__(self): + self.client = None + if SETTINGS['ai']['enabled'] and anthropic is not None: + api_key = os.getenv('ANTHROPIC_API_KEY') + if api_key: + self.client = anthropic.Anthropic(api_key=api_key) + + def load_editions(self) -> List[Dict[str, Any]]: + """Load edition data from editions.js (window.PLOTFLOW = {...};).""" + import re + + with open(EDITIONS_PATH) as f: + content = f.read() + + # The file is `window.PLOTFLOW = { ... };` — valid JSON once the + # assignment wrapper and trailing semicolon are stripped. + m = re.search(r'window\.PLOTFLOW\s*=\s*', content) + if not m: + raise ValueError("Could not find window.PLOTFLOW assignment in editions.js") + payload = content[m.end():].strip().rstrip(';').strip() + + data = json.loads(payload) + suits = data.get('suits', {}) + order = data.get('shopOrder') or data.get('plotterOrder') or list(suits.keys()) + + editions = [] + for key in order: + suit = suits.get(key) + if not suit: + continue + editions.append({ + "key": key, + "code": suit.get('code', ''), + "name": suit.get('name', ''), + "name_jp": suit.get('jp', ''), + "edition": suit.get('edition', ''), + "price": self._parse_price(suit.get('price', '')), + "description": suit.get('lore', ''), + }) + + return editions + + @staticmethod + def _parse_price(price) -> str: + """Normalize price to a display string like '$45'.""" + if isinstance(price, (int, float)): + return f"${price:g}" + s = str(price).strip() + return s if s.startswith('$') else (f"${s}" if s else "") + + def generate_caption(self, edition: Dict[str, Any], post_type: str = "showcase") -> str: + """Generate AI-powered caption for an edition.""" + if not self.client: + return self._template_caption(edition, post_type) + + prompt = f"""Generate an engaging Instagram caption for a machine-drawn artwork with these details: + +Edition: {edition['code']} — {edition['name']} +Japanese: {edition['name_jp']} +Edition run: {edition['edition']} +Price: {edition['price']} +Description: {edition['description']} + +Post type: {post_type} + +Style guidelines: +- {SETTINGS['ai']['style']} +- Tone: {SETTINGS['ai']['tone']} +- Keep it concise (2-4 sentences) +- Include both English and Japanese elements +- Emphasize マシンドロー — drawn by machine, stroke by stroke, never printed +- Mention it's machine-drawn by an AxiDraw plotter +- Make it compelling for art collectors and Gundam fans + +Do not include hashtags in the caption (those are added separately). +""" + + try: + response = self.client.messages.create( + model=SETTINGS['ai']['model'], + max_tokens=300, + messages=[{"role": "user", "content": prompt}] + ) + return response.content[0].text.strip() + except Exception as e: + print(f"AI generation failed: {e}, falling back to template") + return self._template_caption(edition, post_type) + + def _template_caption(self, edition: Dict[str, Any], post_type: str) -> str: + """Template-based caption generation as fallback.""" + templates = { + "showcase": [ + f"{edition['code']} — {edition['name']}\n{edition['name_jp']}\n\n{edition['description']}\n\nDrawn by machine. Never printed. Limited edition.", + f"マシンドロー — drawn by machine.\n\n{edition['name']} ({edition['code']}), traced stroke by stroke in ink by an AxiDraw plotter.\n\nEvery line, every detail — machine precision meets artistic vision.", + f"{edition['name_jp']}\n{edition['name']} · {edition['code']}\n\n{edition['description']}\n\n{edition['price']} · {edition['edition']} · Plotted to order" + ], + "process": [ + f"Watch {edition['name']} come to life.\n\nEach piece starts as precise vector line work. The AxiDraw traces it line by line in technical pen on archival paper.\n\n{edition['code']} · {edition['name_jp']} · Machine drawn", + f"The process: hours of precision.\n\n{edition['name']} plotted in real-time. Stroke by stroke, ink becomes form. Ink meets paper. Machine meets art.\n\nマシンドロー · {edition['code']}" + ], + "behind_scenes": [ + f"In the studio with {edition['code']}.\n\nStrathmore Bristol smooth, Staedtler Triplus 0.3mm fineliner, AxiDraw V3. Every piece drawn to order, inspected, signed, numbered.\n\n{edition['name']} · {edition['name_jp']}", + f"Material matters.\n\nTechnical pen on archival paper. The AxiDraw follows the path with mechanical precision, but ink flow, temperature, and humidity mean no two plots are identical.\n\n{edition['code']} · Drawn by machine" + ] + } + + return random.choice(templates.get(post_type, templates['showcase'])) + + def generate_hashtags(self, edition: Dict[str, Any], post_type: str) -> List[str]: + """Generate relevant hashtags for the post.""" + tags = SETTINGS['content']['hashtags']['core'].copy() + + # Add type-specific tags + if post_type == "showcase": + tags.extend(SETTINGS['content']['hashtags']['art']) + tags.extend(SETTINGS['content']['hashtags']['product']) + elif post_type == "process": + tags.extend(SETTINGS['content']['hashtags']['process']) + tags.extend(SETTINGS['content']['hashtags']['art'][:3]) + else: # behind_scenes + tags.extend(SETTINGS['content']['hashtags']['process']) + + # Add edition-specific tags + tags.append(f"#{edition['code'].replace('-', '')}") + tags.append(f"#{edition['name'].replace(' ', '')}") + + # Limit to max hashtags and shuffle for variety + random.shuffle(tags) + return tags[:SETTINGS['content']['max_hashtags']] + + def create_post_content(self, edition: Dict[str, Any], post_type: str = "showcase") -> Dict[str, Any]: + """Create complete post content package.""" + # Generate multiple caption variations + captions = [] + for i in range(SETTINGS['content']['caption_variants']): + caption = self.generate_caption(edition, post_type) + hashtags = self.generate_hashtags(edition, post_type) + full_caption = f"{caption}\n\n{' '.join(hashtags)}" + captions.append(full_caption) + + return { + "edition": edition['code'], + "edition_key": edition.get('key', ''), + "post_type": post_type, + "captions": captions, + "media_needed": self._get_media_type(post_type), + "created_at": datetime.now().isoformat(), + "metadata": { + "name": edition['name'], + "name_jp": edition['name_jp'], + "price": edition['price'], + "edition": edition['edition'] + } + } + + def _get_media_type(self, post_type: str) -> str: + """Determine media type needed for post. + + Process posts use the plotting video; everything else uses the + showcase still. These map 1:1 to what media_generator produces. + """ + return "video" if post_type == "process" else "image" + + def generate_content_batch(self, count: int = 30) -> List[Dict[str, Any]]: + """Generate a batch of post content.""" + editions = self.load_editions() + post_types = ["showcase", "process", "behind_scenes"] + + content_batch = [] + for i in range(count): + edition = random.choice(editions) + # Vary post types with weighted distribution + post_type = random.choices( + post_types, + weights=[0.5, 0.3, 0.2], # 50% showcase, 30% process, 20% BTS + k=1 + )[0] + + content = self.create_post_content(edition, post_type) + content_batch.append(content) + + return content_batch + + def save_content_batch(self, batch: List[Dict[str, Any]], filename: str = None): + """Save generated content batch to file.""" + if not filename: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"content_batch_{timestamp}.json" + + output_path = SCRIPT_DIR / 'content' / filename + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, 'w') as f: + json.dump(batch, f, indent=2) + + print(f"✓ Saved {len(batch)} posts to {output_path}") + return output_path + + +def main(): + """Generate a batch of Instagram content.""" + print("🤖 PlotFlow Content Generator") + print("=" * 50) + + generator = ContentGenerator() + + # Generate 30 days worth of content (3 posts per day = 90 posts) + print("\nGenerating content batch...") + batch = generator.generate_content_batch(count=90) + + # Save to file + output_path = generator.save_content_batch(batch) + + # Print summary + print(f"\n📊 Content Summary:") + print(f" Total posts: {len(batch)}") + + types = {} + for post in batch: + pt = post['post_type'] + types[pt] = types.get(pt, 0) + 1 + + for post_type, count in types.items(): + print(f" {post_type.title()}: {count}") + + print(f"\n📁 Output: {output_path}") + print("\n✨ Content generation complete!") + + # Show sample + print("\n📝 Sample post:") + print("-" * 50) + sample = batch[0] + print(f"Edition: {sample['edition']}") + print(f"Type: {sample['post_type']}") + print(f"\nCaption variation 1:") + print(sample['captions'][0]) + print("-" * 50) + + +if __name__ == '__main__': + main() diff --git a/marketing/exporter.py b/marketing/exporter.py new file mode 100644 index 0000000..4d8959e --- /dev/null +++ b/marketing/exporter.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +""" +PlotFlow Post Exporter +Turns the scheduled queue into ready-to-post packs you can publish by hand — +no Meta API, no app approval, no account dependency. For each scheduled post it +writes a folder with the media file + caption.txt, and builds a single review +board (index.html) with per-post media preview, the scheduled time, and a +one-click "Copy caption" button. + +This is the manual-assist posting path: open the review page, and for each slot +copy the caption and save/upload the media via the Instagram app in seconds. +""" + +import json +import shutil +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Any, Optional + +SCRIPT_DIR = Path(__file__).parent +GENERATED = SCRIPT_DIR / 'media' / 'generated' +SOCIAL_FALLBACK = SCRIPT_DIR.parent / 'assets' / 'social' +QUEUE_FILE = SCRIPT_DIR / 'content' / 'post_queue.json' +EXPORTS = SCRIPT_DIR / 'exports' + + +def _load_queue() -> List[Dict[str, Any]]: + if QUEUE_FILE.exists(): + return json.loads(QUEUE_FILE.read_text()) + return [] + + +def _load_manifest() -> Dict[str, Any]: + mf = GENERATED / 'manifest.json' + return json.loads(mf.read_text()) if mf.exists() else {} + + +def _find_media(filename: str) -> Optional[Path]: + """Locate a media file locally (generated dir first, then committed assets).""" + for base in (GENERATED, SOCIAL_FALLBACK): + p = base / filename + if p.exists(): + return p + return None + + +def _media_filename(post: Dict[str, Any], manifest: Dict[str, Any]) -> Optional[str]: + key = post.get('edition_key', '') + entry = manifest.get(key, {}) + if post.get('media_needed') == 'video': + return entry.get('video') + return entry.get('still') + + +def export_queue(only_queued: bool = True) -> Path: + """Export scheduled posts into a dated folder of ready-to-post packs.""" + queue = _load_queue() + if only_queued: + queue = [p for p in queue if p.get('status', 'queued') == 'queued'] + + if not queue: + raise SystemExit( + "Nothing to export. Run: python automate.py schedule --days 7" + ) + + manifest = _load_manifest() + queue.sort(key=lambda p: p.get('scheduled_at', '')) + + stamp = datetime.now().strftime('%Y%m%d_%H%M%S') + out_dir = EXPORTS / stamp + out_dir.mkdir(parents=True, exist_ok=True) + + cards: List[Dict[str, Any]] = [] + missing = 0 + + for i, post in enumerate(queue, 1): + caption = post['captions'][0] if post.get('captions') else '' + media_name = _media_filename(post, manifest) + media_src = _find_media(media_name) if media_name else None + + slug = f"{i:02d}_{post.get('edition_key','post')}_{post.get('post_type','')}" + pack = out_dir / slug + pack.mkdir(exist_ok=True) + + # caption.txt + (pack / 'caption.txt').write_text(caption, encoding='utf-8') + + media_rel = None + if media_src: + shutil.copy2(media_src, pack / media_src.name) + media_rel = f"{slug}/{media_src.name}" + else: + missing += 1 + + cards.append({ + "n": i, + "edition": post.get('edition', ''), + "post_type": post.get('post_type', ''), + "media_type": post.get('media_needed', ''), + "scheduled_at": post.get('scheduled_at', ''), + "caption": caption, + "media_rel": media_rel, + "media_name": media_name or "(no media — run media_generator build)", + }) + + (out_dir / 'index.html').write_text(_render_board(cards, stamp), encoding='utf-8') + (out_dir / 'posts.json').write_text(json.dumps(cards, indent=2, ensure_ascii=False), encoding='utf-8') + + print(f"✓ Exported {len(cards)} posts → {out_dir}") + if missing: + print(f"⚠️ {missing} posts have no media. Run: python media_generator.py build") + print(f"\n📋 Open the review board:\n {out_dir / 'index.html'}") + return out_dir + + +def _render_board(cards: List[Dict[str, Any]], stamp: str) -> str: + """Render a self-contained review page with copy-caption buttons.""" + def esc(s: str) -> str: + return (s.replace('&', '&').replace('<', '<') + .replace('>', '>').replace('"', '"')) + + def fmt_when(iso: str) -> str: + try: + return datetime.fromisoformat(iso).strftime('%a %b %d · %H:%M') + except Exception: + return iso or '—' + + items = [] + for c in cards: + if c['media_rel'] and c['media_rel'].endswith('.mp4'): + preview = f'' + elif c['media_rel']: + preview = f'' + else: + preview = '
no media
' + + items.append(f""" +
+
{preview}
+
+
+ {esc(c['post_type'])} · {esc(c['media_type'])} + {esc(fmt_when(c['scheduled_at']))} +
+

#{c['n']} · {esc(c['edition'])}

+ +
+ + {esc(c['media_name'])} +
+
+
""") + + return f""" + + +PLOTFLOW · Post board {stamp} + + +
+

PLOTFLOW* · Post board

+

{len(cards)} posts ready. Copy the caption, save the media, post via the Instagram app. Batch {stamp}.

+
+
{''.join(items)}
+ +""" + + +# ============================================================ +# CARD LIBRARY EXPORT — the 5 editorial card styles (assets/posts) +# ============================================================ + +CARDS_DIR = SCRIPT_DIR.parent / 'assets' / 'posts' +CAPTIONS_FILE = SCRIPT_DIR.parent / 'tools' / 'captions.json' +EDITIONS_FILE = SCRIPT_DIR.parent / 'data' / 'editions.js' + +# Card types in the order we'd post a suit's set, most attention-grabbing first. +CARD_TYPES = ['process', 'lore', 'spec', 'drop'] + +BRAND_CAPTION = ( + "PLOTFLOW* — マシンドロー, drawn by machine.\n\n" + "Universal-Century mobile suits, redrawn as vector line work and " + "traced in pigment ink on archival paper by an AxiDraw plotter. " + "Nothing is printed. Every impression is drawn.\n\n" + "Made to order · signed & numbered · plotflow.io" +) + + +def _load_captions(): + data = json.loads(CAPTIONS_FILE.read_text()) + return data.get('captions', {}), data.get('_footer', '') + + +def _load_order_names(): + c = EDITIONS_FILE.read_text() + d = json.loads(c[c.index('{'):c.rindex('}') + 1]) + order = d.get('shopOrder') or list(d['suits'].keys()) + names = {k: d['suits'][k].get('name', k) for k in d['suits']} + return order, names + + +def export_cards() -> Path: + """Export the editorial card library (assets/posts) as ready-to-post packs. + + Groups by suit (each card style paired with the suit's canon caption), + plus the brand cards. Produces per-card packs + a grouped review board. + """ + if not CARDS_DIR.exists() or not any(CARDS_DIR.glob('post-*.png')): + raise SystemExit("No cards found. Run: python tools/gen_posts.py") + + captions, footer = _load_captions() + order, names = _load_order_names() + + stamp = datetime.now().strftime('%Y%m%d_%H%M%S') + out_dir = EXPORTS / stamp + out_dir.mkdir(parents=True, exist_ok=True) + + groups = [] + n = 0 + + for key in order: + cards = [] + caption = (captions.get(key, '') + footer).strip() + for t in CARD_TYPES: + src = CARDS_DIR / f'post-{key}-{t}.png' + if not src.exists(): + continue + n += 1 + slug = f'{n:02d}_{key}_{t}' + pack = out_dir / slug + pack.mkdir(exist_ok=True) + shutil.copy2(src, pack / src.name) + (pack / 'caption.txt').write_text(caption, encoding='utf-8') + cards.append({'type': t, 'img_rel': f'{slug}/{src.name}', + 'caption': caption, 'file': src.name}) + if cards: + groups.append({'key': key, 'name': names.get(key, key), 'cards': cards}) + + # brand cards + brand_cards = [] + brand_caption = (BRAND_CAPTION + footer).strip() + for variant in ('dark', 'light'): + src = CARDS_DIR / f'post-brand-{variant}.png' + if not src.exists(): + continue + n += 1 + slug = f'{n:02d}_brand_{variant}' + pack = out_dir / slug + pack.mkdir(exist_ok=True) + shutil.copy2(src, pack / src.name) + (pack / 'caption.txt').write_text(brand_caption, encoding='utf-8') + brand_cards.append({'type': f'brand · {variant}', 'img_rel': f'{slug}/{src.name}', + 'caption': brand_caption, 'file': src.name}) + if brand_cards: + groups.append({'key': 'brand', 'name': 'Brand / Studio', 'cards': brand_cards}) + + (out_dir / 'index.html').write_text(_render_card_board(groups, stamp, n), encoding='utf-8') + print(f"✓ Exported {n} cards across {len(groups)} groups → {out_dir}") + print(f"\n📋 Open the review board:\n {out_dir / 'index.html'}") + return out_dir + + +def _render_card_board(groups, stamp, total): + def esc(s): + return (s.replace('&', '&').replace('<', '<') + .replace('>', '>').replace('"', '"')) + + sections = [] + for g in groups: + cards_html = [] + for c in g['cards']: + cards_html.append(f""" +
+
+
+ {esc(c['type'])} + +
+ + {esc(c['file'])} +
+
+
""") + sections.append(f""" +
+

{esc(g['name'])} {len(g['cards'])} cards

+
{''.join(cards_html)}
+
""") + + return f""" + + +PLOTFLOW · Card board {stamp} + + +
+

PLOTFLOW* · Card board

+

{total} cards across {len(groups)} groups. Copy the caption, save the card, post via the Instagram app. Batch {stamp}.

+
+ {''.join(sections)} + +""" + + +def main(): + import argparse + p = argparse.ArgumentParser(description='Export ready-to-post packs') + p.add_argument('--queue', action='store_true', + help='export the old scheduler queue (still+video) instead of the card library') + p.add_argument('--all', action='store_true', help='(queue mode) include posted/failed too') + args = p.parse_args() + if args.queue: + export_queue(only_queued=not args.all) + else: + export_cards() + + +if __name__ == '__main__': + main() diff --git a/marketing/instagram_api.py b/marketing/instagram_api.py new file mode 100755 index 0000000..770960a --- /dev/null +++ b/marketing/instagram_api.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +Instagram Graph API Integration +Handles posting images, videos, and carousels to Instagram Business accounts. +""" + +import json +import time +import requests +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Any + +# Load settings +SCRIPT_DIR = Path(__file__).parent +with open(SCRIPT_DIR / 'config/settings.json') as f: + SETTINGS = json.load(f) + + +class InstagramAPI: + """Instagram Graph API wrapper for posting content.""" + + def __init__(self, access_token: str = None, account_id: str = None): + ig = SETTINGS['instagram'] + self.access_token = access_token or ig['access_token'] + self.account_id = account_id or ig['account_id'] + self.api_version = ig['api_version'] + # Two valid hosts depending on how you set up the app in Meta: + # graph.instagram.com → "Instagram API with Instagram login" + # (no Facebook Page needed — simplest for + # publishing to your own @plotflow account) + # graph.facebook.com → "Instagram API with Facebook login" + # (requires an IG account linked to a FB Page) + # Set instagram.graph_host in settings.json to pick. Defaults to the + # Instagram-login host since @plotflow is a professional account. + host = ig.get('graph_host', 'graph.instagram.com') + self.base_url = f"https://{host}/{self.api_version}" + + def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]: + """Make API request with error handling.""" + url = f"{self.base_url}/{endpoint}" + kwargs.setdefault('params', {})['access_token'] = self.access_token + + try: + response = requests.request(method, url, **kwargs) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as e: + error_data = response.json() if response.text else {} + print(f"❌ API Error: {error_data}") + raise + + def upload_media(self, media_url: str, caption: str, media_type: str = "IMAGE") -> str: + """ + Upload media and create container. + + Args: + media_url: Publicly accessible URL of the image/video + caption: Post caption with hashtags + media_type: "IMAGE" or "VIDEO" + + Returns: + Container ID for publishing + """ + endpoint = f"{self.account_id}/media" + + data = { + "caption": caption, + } + + if media_type == "VIDEO": + data["media_type"] = "VIDEO" + data["video_url"] = media_url + else: + data["image_url"] = media_url + + print(f"📤 Creating media container... ({media_type})") + result = self._make_request('POST', endpoint, params=data) + container_id = result.get('id') + + # For videos, wait for processing + if media_type == "VIDEO": + print(f"⏳ Processing video...") + self._wait_for_video_processing(container_id) + + return container_id + + def _wait_for_video_processing(self, container_id: str, timeout: int = 300): + """Wait for video to finish processing.""" + start_time = time.time() + while time.time() - start_time < timeout: + status = self._make_request('GET', container_id, params={'fields': 'status_code'}) + status_code = status.get('status_code') + + if status_code == 'FINISHED': + print("✓ Video processing complete") + return + elif status_code == 'ERROR': + raise Exception("Video processing failed") + + print(f" Status: {status_code}...") + time.sleep(10) + + raise TimeoutError("Video processing timeout") + + def create_carousel(self, media_urls: List[str], caption: str) -> str: + """ + Create carousel post container. + + Args: + media_urls: List of publicly accessible image URLs + caption: Post caption + + Returns: + Container ID for publishing + """ + # First, create containers for each image + children = [] + for i, url in enumerate(media_urls): + print(f"📤 Uploading carousel item {i+1}/{len(media_urls)}...") + item_data = { + "image_url": url, + "is_carousel_item": True + } + result = self._make_request('POST', f"{self.account_id}/media", params=item_data) + children.append(result['id']) + + # Create carousel container + print(f"📦 Creating carousel container...") + carousel_data = { + "caption": caption, + "media_type": "CAROUSEL", + "children": ','.join(children) + } + result = self._make_request('POST', f"{self.account_id}/media", params=carousel_data) + return result['id'] + + def publish_post(self, container_id: str) -> Dict[str, Any]: + """ + Publish a media container to Instagram. + + Args: + container_id: Media container ID from upload_media or create_carousel + + Returns: + Published post data including post ID + """ + print(f"📢 Publishing to Instagram...") + endpoint = f"{self.account_id}/media_publish" + result = self._make_request('POST', endpoint, params={'creation_id': container_id}) + + post_id = result.get('id') + print(f"✓ Published! Post ID: {post_id}") + return result + + def post_image(self, image_url: str, caption: str) -> str: + """Post single image (convenience method).""" + container_id = self.upload_media(image_url, caption, "IMAGE") + result = self.publish_post(container_id) + return result['id'] + + def post_video(self, video_url: str, caption: str) -> str: + """Post video (convenience method).""" + container_id = self.upload_media(video_url, caption, "VIDEO") + result = self.publish_post(container_id) + return result['id'] + + def post_carousel(self, image_urls: List[str], caption: str) -> str: + """Post carousel (convenience method).""" + container_id = self.create_carousel(image_urls, caption) + result = self.publish_post(container_id) + return result['id'] + + def get_account_info(self) -> Dict[str, Any]: + """Get Instagram account information.""" + endpoint = self.account_id + return self._make_request('GET', endpoint, params={ + 'fields': 'name,username,profile_picture_url,followers_count,media_count' + }) + + def test_connection(self) -> bool: + """Test API connection and credentials.""" + try: + info = self.get_account_info() + print(f"✓ Connected to Instagram account: @{info.get('username')}") + print(f" Followers: {info.get('followers_count', 'N/A')}") + print(f" Posts: {info.get('media_count', 'N/A')}") + return True + except Exception as e: + print(f"❌ Connection failed: {e}") + return False + + +def main(): + """Test Instagram API connection.""" + print("🔌 Testing Instagram API Connection") + print("=" * 50) + + api = InstagramAPI() + + if api.access_token == "YOUR_INSTAGRAM_ACCESS_TOKEN": + print("\n⚠️ No access token configured!") + print("\nTo set up Instagram API:") + print("1. Create a Facebook App at developers.facebook.com") + print("2. Add Instagram Graph API product") + print("3. Connect your Instagram Business account") + print("4. Generate an access token") + print("5. Update marketing/config/settings.json with your credentials") + print("\nSee: https://developers.facebook.com/docs/instagram-api/getting-started") + return + + # Test connection + api.test_connection() + + +if __name__ == '__main__': + main() diff --git a/marketing/media_generator.py b/marketing/media_generator.py new file mode 100644 index 0000000..868136e --- /dev/null +++ b/marketing/media_generator.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +""" +PlotFlow Media Generator +Renders each edition's continuous-line path into Instagram-ready media: + + • process video (.mp4) — the suit drawn line-by-line, the way the site's + Live Plot animates it, on Strathmore Bristol paper in red ink. + • showcase still (.png) — the finished plot, composed as a feed image. + +Self-contained: parses the M/L path data straight out of data/editions.js, +draws with Pillow, and encodes with the ffmpeg binary bundled by +imageio-ffmpeg (no system ffmpeg required). Cron-friendly, no browser. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path +from typing import Dict, List, Tuple, Any, Optional + +import numpy as np +from PIL import Image, ImageDraw, ImageFont +import imageio.v2 as imageio +import imageio_ffmpeg + +SCRIPT_DIR = Path(__file__).parent +EDITIONS_PATH = SCRIPT_DIR.parent / 'data/editions.js' + +with open(SCRIPT_DIR / 'config/settings.json') as f: + SETTINGS = json.load(f) + +# Authentic PlotFlow palette (from styles/tokens.css) +BRISTOL = (246, 243, 236) # --bristol : the live-plot paper +RED = (232, 53, 31) # --red : the ink +DIM = (67, 70, 62) # --con2 : frame + labels +INK_DARK = (21, 22, 15) # --ink + +# Fonts (Latin + CJK) discovered on the host +FONT_LATIN = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" +FONT_JP = "/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf" + +Point = Tuple[float, float] +Segment = Tuple[float, float, float, float, float, float] # x0,y0,x1,y1,len,cum_start + + +class PlotRenderer: + """Renders continuous-line paths to video + still media.""" + + def __init__(self): + self.suits = self._load_suits() + self.export_dir = SCRIPT_DIR / 'media' / 'generated' + self.export_dir.mkdir(parents=True, exist_ok=True) + + # ---- data --------------------------------------------------------------- + + def _load_suits(self) -> Dict[str, Any]: + """Parse window.PLOTFLOW from editions.js (valid JSON once unwrapped).""" + with open(EDITIONS_PATH) as f: + content = f.read() + m = re.search(r'window\.PLOTFLOW\s*=\s*', content) + if not m: + raise ValueError("Could not find window.PLOTFLOW in editions.js") + payload = content[m.end():].strip().rstrip(';').strip() + data = json.loads(payload) + return data.get('suits', {}) + + def order(self) -> List[str]: + return list(self.suits.keys()) + + # ---- geometry ----------------------------------------------------------- + + @staticmethod + def _parse_path(d: str) -> List[List[Point]]: + """Parse an M/L-only path string into a list of polylines. + + In this data 'M' is a pen lift (start of a new stroke) and 'L' draws + to the next point — exactly how the AxiDraw walks the geometry. + """ + tokens = d.replace(',', ' ').split() + polylines: List[List[Point]] = [] + cur: Optional[List[Point]] = None + i, n = 0, len(tokens) + while i < n: + t = tokens[i] + if t in ('M', 'L'): + x = float(tokens[i + 1]); y = float(tokens[i + 2]); i += 3 + if t == 'M': + cur = [(x, y)] + polylines.append(cur) + else: + if cur is None: + cur = [(x, y)]; polylines.append(cur) + else: + cur.append((x, y)) + else: + i += 1 + return polylines + + @staticmethod + def _flatten(polylines: List[List[Point]]) -> Tuple[List[Segment], float, Tuple[float, float, float, float]]: + """Turn polylines into a flat, arc-length-indexed segment list.""" + segs: List[Segment] = [] + cum = 0.0 + minx = miny = float('inf') + maxx = maxy = float('-inf') + + def note(px, py): + nonlocal minx, miny, maxx, maxy + minx = min(minx, px); miny = min(miny, py) + maxx = max(maxx, px); maxy = max(maxy, py) + + for poly in polylines: + for (x, y) in poly: + note(x, y) + for (x0, y0), (x1, y1) in zip(poly, poly[1:]): + length = ((x1 - x0) ** 2 + (y1 - y0) ** 2) ** 0.5 + if length <= 0: + continue + segs.append((x0, y0, x1, y1, length, cum)) + cum += length + + if minx == float('inf'): + minx = miny = 0.0; maxx = maxy = 1.0 + return segs, cum, (minx, miny, maxx, maxy) + + @staticmethod + def _make_transform(bbox, cw, ch, ox, oy): + """Fit bbox into a cw×ch content box at offset (ox,oy), preserve aspect.""" + minx, miny, maxx, maxy = bbox + bw = max(1e-6, maxx - minx) + bh = max(1e-6, maxy - miny) + pad = max(bw, bh) * 0.06 + minx -= pad; miny -= pad; bw += 2 * pad; bh += 2 * pad + s = min(cw / bw, ch / bh) + # center within the content box + tx = ox + (cw - bw * s) / 2 - minx * s + ty = oy + (ch - bh * s) / 2 - miny * s + + def tf(x, y): + return (x * s + tx, y * s + ty) + return tf, s + + # ---- drawing helpers ---------------------------------------------------- + + def _base_paper(self, w: int, h: int) -> Image.Image: + """Bristol sheet with a subtle inset frame, echoing the plot bed.""" + img = Image.new('RGB', (w, h), BRISTOL) + d = ImageDraw.Draw(img) + m = int(min(w, h) * 0.035) + d.rectangle([m, m, w - m, h - m], outline=DIM + (0,) if False else DIM, width=max(1, w // 900)) + return img + + def _font(self, path: str, size: int) -> ImageFont.FreeTypeFont: + try: + return ImageFont.truetype(path, size) + except Exception: + return ImageFont.load_default() + + def _draw_labels(self, img: Image.Image, suit: Dict[str, Any], ss: int): + """Corner bed label + name/JP, plotter-HUD style.""" + d = ImageDraw.Draw(img) + w, h = img.size + pad = int(w * 0.06) + f_small = self._font(FONT_LATIN, int(w * 0.022)) + f_name = self._font(FONT_LATIN, int(w * 0.040)) + f_jp = self._font(FONT_JP, int(w * 0.030)) + + # top-left: BED 01 · CODE + d.text((pad, pad), f"BED 01 · {suit.get('code', '')}", font=f_small, fill=DIM) + # top-right: マシンドロー (drawn by machine) + machine = "マシンドロー" + bbox = d.textbbox((0, 0), machine, font=f_jp) + d.text((w - pad - (bbox[2] - bbox[0]), pad), machine, font=f_jp, fill=DIM) + + # bottom-left: name + JP + name = suit.get('name', '') + jp = suit.get('jp', '') + d.text((pad, h - pad - int(w * 0.075)), name, font=f_name, fill=INK_DARK) + d.text((pad, h - pad - int(w * 0.032)), jp, font=f_jp, fill=DIM) + + # ---- public: video ------------------------------------------------------ + + def render_video(self, key: str, size: int = 1080, fps: int = 30, + duration: float = 14.0, supersample: int = 2, + out_path: Optional[Path] = None) -> Path: + """Render the line-by-line plotting animation to an MP4.""" + suit = self.suits[key] + segs, total_len, bbox = self._flatten(self._parse_path(suit['d'])) + if total_len <= 0: + raise ValueError(f"{key}: path has zero length") + + ss = supersample + W = size * ss + m = int(W * 0.035) + content = (W - 2 * m, W - 2 * m) + tf, scale = self._make_transform(bbox, content[0], content[1], m, m) + line_w = max(1, int(W * 0.0022)) + pen_r = max(2, int(W * 0.010)) + + paper = self._base_paper(W, W) + ink = Image.new('RGBA', (W, W), (0, 0, 0, 0)) + idraw = ImageDraw.Draw(ink) + + frames = max(2, int(fps * duration)) + out_path = out_path or (self.export_dir / f"{key}_process.mp4") + tmp_video = out_path.with_suffix('.noaudio.mp4') + + # ease-in-out so the pen starts/settles gently + def ease(t): return t * t * (3 - 2 * t) + + writer = imageio.get_writer( + str(tmp_video), fps=fps, codec='libx264', quality=8, + macro_block_size=None, + ffmpeg_params=['-pix_fmt', 'yuv420p', '-movflags', '+faststart'], + ) + + drawn_len = 0.0 + cursor = 0 # index into segs; advances monotonically + head = None + try: + for i in range(frames + 1): + target = ease(i / frames) * total_len + # extend ink from drawn_len -> target + j = cursor + while j < len(segs): + x0, y0, x1, y1, slen, cstart = segs[j] + cend = cstart + slen + if cend <= drawn_len: + j += 1; continue + if cstart >= target: + break + a = max(drawn_len, cstart); b = min(target, cend) + ta = (a - cstart) / slen + tb = (b - cstart) / slen + ax, ay = x0 + (x1 - x0) * ta, y0 + (y1 - y0) * ta + bx, by = x0 + (x1 - x0) * tb, y0 + (y1 - y0) * tb + idraw.line([tf(ax, ay), tf(bx, by)], fill=RED + (255,), + width=line_w, joint='curve') + head = tf(bx, by) + if cend <= target: + j += 1 + else: + break + cursor = j + drawn_len = target + + # composite paper + ink + pen head + frame = paper.copy() + frame.paste(ink, (0, 0), ink) + if head is not None and i < frames: + fd = ImageDraw.Draw(frame) + hx, hy = head + fd.ellipse([hx - pen_r, hy - pen_r, hx + pen_r, hy + pen_r], + outline=INK_DARK, width=max(1, line_w)) + fd.line([hx - pen_r * 1.7, hy, hx + pen_r * 1.7, hy], fill=INK_DARK, width=1) + fd.line([hx, hy - pen_r * 1.7, hx, hy + pen_r * 1.7], fill=INK_DARK, width=1) + + self._draw_labels(frame, suit, ss) + out = frame.resize((size, size), Image.LANCZOS) if ss > 1 else frame + writer.append_data(np.asarray(out)) + + # hold the finished frame for ~1s + for _ in range(fps): + writer.append_data(np.asarray(out)) + finally: + writer.close() + + self._add_silent_audio(tmp_video, out_path) + tmp_video.unlink(missing_ok=True) + return out_path + + # ---- public: still ------------------------------------------------------ + + def render_still(self, key: str, width: int = 1080, height: int = 1350, + supersample: int = 2, out_path: Optional[Path] = None) -> Path: + """Render the finished plot as a feed still (4:5 portrait by default).""" + suit = self.suits[key] + segs, total_len, bbox = self._flatten(self._parse_path(suit['d'])) + + ss = supersample + W, H = width * ss, height * ss + m = int(W * 0.05) + # leave headroom at the bottom for the label block + content = (W - 2 * m, H - 2 * m - int(H * 0.10)) + tf, scale = self._make_transform(bbox, content[0], content[1], m, m) + line_w = max(1, int(W * 0.0024)) + + img = Image.new('RGB', (W, H), BRISTOL) + d = ImageDraw.Draw(img) + d.rectangle([m, m, W - m, H - m], outline=DIM, width=max(1, W // 900)) + + for (x0, y0, x1, y1, slen, cstart) in segs: + d.line([tf(x0, y0), tf(x1, y1)], fill=RED, width=line_w, joint='curve') + + # label block + pad = m + f_code = self._font(FONT_LATIN, int(W * 0.020)) + f_name = self._font(FONT_LATIN, int(W * 0.045)) + f_jp = self._font(FONT_JP, int(W * 0.032)) + by = H - m - int(H * 0.085) + d.text((pad, by), f"{suit.get('code', '')} · {suit.get('edition', '')}", font=f_code, fill=DIM) + d.text((pad, by + int(W * 0.028)), suit.get('name', ''), font=f_name, fill=INK_DARK) + jp = suit.get('jp', '') + bbox_jp = d.textbbox((0, 0), jp, font=f_jp) + d.text((W - pad - (bbox_jp[2] - bbox_jp[0]), by + int(W * 0.030)), jp, font=f_jp, fill=DIM) + # drawn-by-machine signature (no price — gallery, not storefront) + d.text((W - pad - int(W * 0.22), by), 'マシンドロー', font=self._font(FONT_JP, int(W * 0.024)), fill=RED) + + out_path = out_path or (self.export_dir / f"{key}_showcase.png") + final = img.resize((width, height), Image.LANCZOS) if ss > 1 else img + final.save(out_path, 'PNG') + return out_path + + # ---- encoding ----------------------------------------------------------- + + def _add_silent_audio(self, video_in: Path, video_out: Path): + """Mux a silent AAC track (some IG endpoints require an audio stream).""" + ffmpeg = imageio_ffmpeg.get_ffmpeg_exe() + cmd = [ + ffmpeg, '-y', '-i', str(video_in), + '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', + '-shortest', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k', + '-movflags', '+faststart', str(video_out), + ] + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + # ---- batch build + manifest -------------------------------------------- + + def build_all(self, keys: Optional[List[str]] = None, video_duration: float = 14.0, + publish_dir: Optional[Path] = None) -> Dict[str, Any]: + """Render video + still for each edition and write a manifest. + + The manifest maps each edition key to its media filenames and (if a + site URL is configured) the public URLs the Instagram API will fetch. + When publish_dir is given, media is also copied there — point it at a + folder served by GitHub Pages (e.g. ../assets/social) so the files are + publicly reachable at your domain. + """ + keys = keys or self.order() + site = SETTINGS.get('site', {}) + site_url = (site.get('url') or '').rstrip('/') + social_path = (site.get('social_path') or 'assets/social').strip('/') + + manifest: Dict[str, Any] = {} + for key in keys: + print(f"🎬 {key}: video …") + video = self.render_video(key, duration=video_duration) + print(f"🖼 {key}: still …") + still = self.render_still(key) + + entry: Dict[str, str] = {"video": video.name, "still": still.name} + + if publish_dir: + publish_dir = Path(publish_dir) + publish_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(video, publish_dir / video.name) + shutil.copy2(still, publish_dir / still.name) + + if site_url: + base = f"{site_url}/{social_path}" + entry["video_url"] = f"{base}/{video.name}" + entry["still_url"] = f"{base}/{still.name}" + + manifest[key] = entry + + manifest_path = self.export_dir / 'manifest.json' + manifest_path.write_text(json.dumps(manifest, indent=2)) + print(f"\n✓ Manifest → {manifest_path}") + return manifest + + +class MediaLibrary: + """Reads the media manifest so the poster can resolve edition → media URL.""" + + def __init__(self, manifest_path: Optional[Path] = None): + self.manifest_path = manifest_path or (SCRIPT_DIR / 'media' / 'generated' / 'manifest.json') + self.manifest = {} + if self.manifest_path.exists(): + self.manifest = json.loads(self.manifest_path.read_text()) + + def url_for(self, key: str, media_type: str) -> Optional[str]: + """Return the public URL for an edition's video or image, if known.""" + entry = self.manifest.get(key) + if not entry: + return None + field = 'video_url' if media_type == 'video' else 'still_url' + return entry.get(field) + + def has(self, key: str) -> bool: + return key in self.manifest + + +def main(): + import argparse + p = argparse.ArgumentParser(description='PlotFlow media generator') + p.add_argument('command', choices=['video', 'still', 'all', 'build', 'list']) + p.add_argument('--key', help='edition key (e.g. zaku). Omit for all editions.') + p.add_argument('--duration', type=float, default=14.0, help='video seconds (default 14)') + p.add_argument('--size', type=int, default=1080) + p.add_argument('--publish-dir', help='also copy media here (e.g. ../assets/social) for public hosting') + args = p.parse_args() + + r = PlotRenderer() + + if args.command == 'list': + print("Available editions:") + for k in r.order(): + s = r.suits[k] + print(f" {k:12s} {s.get('code',''):10s} {s.get('name','')}") + return + + if args.command == 'build': + # Render everything + write the manifest the poster consumes. + keys = [args.key] if args.key else None + r.build_all(keys=keys, video_duration=args.duration, + publish_dir=args.publish_dir) + print("\n✨ Media build complete!") + return + + keys = [args.key] if args.key else r.order() + for key in keys: + if args.command in ('video', 'all'): + print(f"🎬 Rendering video: {key} ...") + out = r.render_video(key, size=args.size, duration=args.duration) + print(f" ✓ {out} ({out.stat().st_size // 1024} KB)") + if args.command in ('still', 'all'): + print(f"🖼 Rendering still: {key} ...") + out = r.render_still(key, width=args.size) + print(f" ✓ {out} ({out.stat().st_size // 1024} KB)") + + print("\n✨ Media generation complete!") + + +if __name__ == '__main__': + main() diff --git a/marketing/requirements.txt b/marketing/requirements.txt new file mode 100644 index 0000000..d62d071 --- /dev/null +++ b/marketing/requirements.txt @@ -0,0 +1,20 @@ +# PlotFlow Marketing Automation Requirements + +# Instagram API +requests>=2.31.0 + +# AI Content Generation +anthropic>=0.34.0 + +# Scheduling & Time +pytz>=2024.1 +python-dateutil>=2.8.2 + +# Media generation (process videos + showcase stills) +Pillow>=10.0.0 +numpy>=1.24.0 +imageio>=2.31.0 +imageio-ffmpeg>=0.4.9 # bundles a static ffmpeg binary — no system ffmpeg needed + +# Data handling +python-dotenv>=1.0.0 diff --git a/marketing/scheduler.py b/marketing/scheduler.py new file mode 100755 index 0000000..28b92d1 --- /dev/null +++ b/marketing/scheduler.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Post Scheduler +Manages posting queue and timing for Instagram automation. +""" + +import json +import random +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Dict, Any, Optional +import pytz + +# Load settings +SCRIPT_DIR = Path(__file__).parent +with open(SCRIPT_DIR / 'config/settings.json') as f: + SETTINGS = json.load(f) + + +class PostScheduler: + """Manages scheduling and queuing of Instagram posts.""" + + def __init__(self): + self.timezone = pytz.timezone(SETTINGS['posting']['schedule']['timezone']) + self.queue_file = SCRIPT_DIR / 'content/post_queue.json' + self.history_file = SCRIPT_DIR / 'content/post_history.json' + self.queue = self._load_queue() + self.history = self._load_history() + + def _load_queue(self) -> List[Dict[str, Any]]: + """Load posting queue from file.""" + if self.queue_file.exists(): + with open(self.queue_file) as f: + return json.load(f) + return [] + + def _save_queue(self): + """Save posting queue to file.""" + self.queue_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.queue_file, 'w') as f: + json.dump(self.queue, f, indent=2) + + def _load_history(self) -> List[Dict[str, Any]]: + """Load posting history.""" + if self.history_file.exists(): + with open(self.history_file) as f: + return json.load(f) + return [] + + def _save_history(self): + """Save posting history.""" + with open(self.history_file, 'w') as f: + json.dump(self.history, f, indent=2) + + def generate_schedule(self, content_batch: List[Dict[str, Any]], start_date: datetime = None) -> List[Dict[str, Any]]: + """ + Generate posting schedule from content batch. + + Args: + content_batch: List of generated post content + start_date: When to start scheduling (default: tomorrow) + + Returns: + Scheduled posts with dates/times + """ + if start_date is None: + start_date = datetime.now(self.timezone) + timedelta(days=1) + start_date = start_date.replace(hour=9, minute=0, second=0, microsecond=0) + + schedule_settings = SETTINGS['posting']['schedule'] + post_times = schedule_settings['times'] + active_days = [d.lower() for d in schedule_settings['days']] + daily_limit = SETTINGS['posting']['frequency']['daily_limit'] + + scheduled_posts = [] + current_date = start_date + posts_today = 0 + + for post_content in content_batch: + # Check if we've hit daily limit + if posts_today >= daily_limit: + current_date += timedelta(days=1) + posts_today = 0 + + # Skip if day is not active + while current_date.strftime('%A').lower() not in active_days: + current_date += timedelta(days=1) + posts_today = 0 + + # Pick a random time from configured times + time_str = random.choice(post_times) + hour, minute = map(int, time_str.split(':')) + scheduled_time = current_date.replace(hour=hour, minute=minute) + + # Add some randomness (±15 minutes) to feel more natural + random_offset = random.randint(-15, 15) + scheduled_time += timedelta(minutes=random_offset) + + scheduled_post = { + **post_content, + "scheduled_at": scheduled_time.isoformat(), + "status": "queued", + "id": f"{post_content['edition']}_{scheduled_time.strftime('%Y%m%d_%H%M')}" + } + + scheduled_posts.append(scheduled_post) + posts_today += 1 + + return scheduled_posts + + def add_to_queue(self, posts: List[Dict[str, Any]]): + """Add posts to the posting queue.""" + self.queue.extend(posts) + self.queue.sort(key=lambda x: x['scheduled_at']) + self._save_queue() + print(f"✓ Added {len(posts)} posts to queue") + + def get_pending_posts(self, until: datetime = None) -> List[Dict[str, Any]]: + """Get posts that are due to be posted.""" + if until is None: + until = datetime.now(self.timezone) + + until_iso = until.isoformat() + + pending = [ + post for post in self.queue + if post['status'] == 'queued' and post['scheduled_at'] <= until_iso + ] + + return pending + + def mark_posted(self, post_id: str, instagram_post_id: str = None): + """Mark a post as published.""" + for post in self.queue: + if post['id'] == post_id: + post['status'] = 'posted' + post['posted_at'] = datetime.now(self.timezone).isoformat() + if instagram_post_id: + post['instagram_id'] = instagram_post_id + + # Move to history + self.history.append(post) + self.queue.remove(post) + + self._save_queue() + self._save_history() + break + + def mark_failed(self, post_id: str, error: str): + """Mark a post as failed.""" + for post in self.queue: + if post['id'] == post_id: + post['status'] = 'failed' + post['error'] = error + post['failed_at'] = datetime.now(self.timezone).isoformat() + self._save_queue() + break + + def get_stats(self) -> Dict[str, Any]: + """Get scheduling statistics.""" + now = datetime.now(self.timezone) + + queued = [p for p in self.queue if p['status'] == 'queued'] + failed = [p for p in self.queue if p['status'] == 'failed'] + + # Find next post time + next_post = None + if queued: + next_post = min(queued, key=lambda x: x['scheduled_at']) + + return { + "total_queued": len(queued), + "total_posted": len(self.history), + "total_failed": len(failed), + "next_post": next_post['scheduled_at'] if next_post else None, + "next_post_edition": next_post['edition'] if next_post else None, + "current_time": now.isoformat() + } + + +def main(): + """Example usage of scheduler.""" + print("📅 Post Scheduler") + print("=" * 50) + + scheduler = PostScheduler() + + # Show current stats + stats = scheduler.get_stats() + print(f"\n📊 Current Status:") + print(f" Queued: {stats['total_queued']}") + print(f" Posted: {stats['total_posted']}") + print(f" Failed: {stats['total_failed']}") + + if stats['next_post']: + next_time = datetime.fromisoformat(stats['next_post']) + print(f"\n⏰ Next Post:") + print(f" Edition: {stats['next_post_edition']}") + print(f" Time: {next_time.strftime('%Y-%m-%d %H:%M %Z')}") + + # Check for pending posts + pending = scheduler.get_pending_posts() + if pending: + print(f"\n🔔 {len(pending)} posts ready to publish!") + for post in pending[:3]: # Show first 3 + print(f" - {post['edition']} ({post['post_type']})") + + +if __name__ == '__main__': + main() diff --git a/mockups/redesign/redesign-a-console.html b/mockups/redesign/redesign-a-console.html new file mode 100644 index 0000000..f1d3463 --- /dev/null +++ b/mockups/redesign/redesign-a-console.html @@ -0,0 +1,199 @@ + + +PLOTFLOW · Console direction + + +
+ PLOTFLOW* + CONSOLE v2.0 + AXIDRAW ONLINE + PLOTTING · MS-06 + + +
+ +
+
+
+
+
+ BED 01 · MS-06 · 300gsm BRISTOL + DRAWN IN EXECUTION ORDER · INK 0.30 MM +
+ +
+

DRAWN,
NOT PRINTED.

+

マシンドロー · DRAWN BY MACHINE · EST. 2026

+
+
+ + +
+ +
+ +
+

THE WORK*

6 EDITIONS · LIMITED TO 25 · SIGNED & NUMBERED
+
+
+ + + + + + diff --git a/mockups/redesign/redesign-b-gallery.html b/mockups/redesign/redesign-b-gallery.html new file mode 100644 index 0000000..111bcc4 --- /dev/null +++ b/mockups/redesign/redesign-b-gallery.html @@ -0,0 +1,94 @@ + + +PLOTFLOW · Gallery direction + + + + +
+
INDEPENDENT PEN-PLOTTER STUDIO · EST. 2026
+

Six machines.
Six editions.
Drawn by machine.

+
+ +

Universal-Century mobile suits, redrawn as vector line work and traced stroke by stroke in pigment ink on archival Bristol by an AxiDraw plotter. Nothing is printed. Every impression is drawn.

+
+
+
+ +
+ +
+

Ink flows by gravity, temperature, and the speed of the carriage. No two plots are identical.

+
マシンドロー — drawn by machine, finished by hand
+
+ + + + + + diff --git a/mockups/redesign/redesign-c-spectrum.html b/mockups/redesign/redesign-c-spectrum.html new file mode 100644 index 0000000..a2c0bc8 --- /dev/null +++ b/mockups/redesign/redesign-c-spectrum.html @@ -0,0 +1,140 @@ + + +PLOTFLOW · Spectrum direction + + + + +
+
+
+
マシンドロー · DRAWN BY MACHINE · NEVER PRINTED
+

Every suit.
Every line.
Drawn by machine.

+

Universal-Century mobile suits redrawn as precise vector line work and traced in pigment ink on archival Bristol by an AxiDraw plotter. The color shows the plot order — first stroke to last.

+
+ + ▶︎ WATCH IT PLOT +
+
FIRST STROKELAST STROKE
+
+
+ +
MS-06 · ZAKU II · ザク
4,277 VERTICES · 22.9m OF INK
+
+
+ +
+
6EDITIONS
+
25PER EDITION · SIGNED
+
135mINK PER FULL SET
+
0PRINTS. EVER.
+
+ +
+

The Editions

MADE TO ORDER · 2–3 WEEK PLOT QUEUE
+
+
+ +
+
+

Watch yours being drawn.

+

Every order is plotted live. Follow the pen from the first vertex to the signature — stroke by stroke — drawn, never printed.

+ +
+
+ + + + + + diff --git a/preview.html b/preview.html index eeafb41..c69c8eb 100644 --- a/preview.html +++ b/preview.html @@ -3,13 +3,17 @@ - + PLOTFLOW · マシンドロー @@ -333,7 +337,7 @@

The Work*

作品 const bed=document.getElementById('bed'), bedlabel=document.getElementById('bedlabel'); const selSuit=document.getElementById('suit'), playBtn=document.getElementById('play'), replay=document.getElementById('replay'); -const FEED=1100, BASE=22; +const FEED=1100, BASE=30; let cur, len=1, drawn=0, playing=true, speed=1, mmPerUnit=1, totalMin=1; ORDER.forEach(k=>{const o=document.createElement('option');o.value=k;o.textContent=SUITS[k].name+" · "+SUITS[k].code;selSuit.appendChild(o);}); diff --git a/product.html b/product.html index 1a245f2..02e74bc 100644 --- a/product.html +++ b/product.html @@ -3,13 +3,17 @@ - + Edition · PLOTFLOW* diff --git a/returns.html b/returns.html index 7b7199f..1d30989 100644 --- a/returns.html +++ b/returns.html @@ -3,13 +3,17 @@ - + Returns · PLOTFLOW* diff --git a/scripts/consent.js b/scripts/consent.js new file mode 100644 index 0000000..89aece0 --- /dev/null +++ b/scripts/consent.js @@ -0,0 +1,85 @@ +/* ============================================================ + PLOTFLOW · Cookie consent — GA4 Consent Mode v2, opt-in (EU pattern) + The gtag.js loader is NOT injected until the visitor accepts; + declining stores the choice and never loads it. Consent defaults + are set to 'denied' in each page's before this runs. + Reopen anytime via the "Cookie settings" link this adds to the footer. + ============================================================ */ +(function () { + var KEY = 'pf-consent'; + var GA_ID = 'G-1HJ4VQ4K1B'; + + function choice() { try { return localStorage.getItem(KEY); } catch (e) { return null; } } + function store(v) { try { localStorage.setItem(KEY, v); } catch (e) { /* private mode */ } } + + function loadGA() { + if (document.getElementById('pf-ga')) return; + gtag('consent', 'update', { analytics_storage: 'granted' }); + var s = document.createElement('script'); + s.id = 'pf-ga'; + s.async = true; + s.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_ID; + document.head.appendChild(s); + } + + var STYLE = + '#pf-consent{position:fixed;left:18px;bottom:18px;z-index:9999;width:400px;max-width:calc(100vw - 36px);' + + 'background:rgba(21,23,15,.95);border:1px solid rgba(245,244,239,.22);box-shadow:0 16px 44px rgba(0,0,0,.45);' + + 'padding:16px 18px;color:#c3c5b8;font-size:13px;line-height:1.6;backdrop-filter:blur(10px)}' + + '#pf-consent .hd{display:flex;justify-content:space-between;font-size:9.5px;letter-spacing:.2em;' + + 'color:#8f9184;margin-bottom:8px;text-transform:uppercase;font-weight:700}' + + '#pf-consent .hd b{color:#f6f3ec}' + + '#pf-consent p{margin:0 0 12px}' + + '#pf-consent .btns{display:flex;gap:10px;align-items:center}' + + '#pf-consent button{cursor:pointer;font-family:inherit;font-size:10.5px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}' + + '#pf-consent .ok{background:#e8351f;border:none;color:#fff;padding:9px 18px}' + + '#pf-consent .no{background:none;border:1px solid rgba(245,244,239,.28);color:#c3c5b8;padding:8px 16px}' + + '.pf-consent-link{cursor:pointer;text-decoration:underline}' + + '@media (max-width:520px){#pf-consent{left:10px;right:10px;bottom:10px;width:auto}}'; + + function removeBanner() { + var el = document.getElementById('pf-consent'); + if (el) el.remove(); + } + + function showBanner() { + if (document.getElementById('pf-consent')) return; + if (!document.getElementById('pf-consent-style')) { + var st = document.createElement('style'); + st.id = 'pf-consent-style'; + st.textContent = STYLE; + document.head.appendChild(st); + } + var el = document.createElement('div'); + el.id = 'pf-consent'; + el.setAttribute('role', 'dialog'); + el.setAttribute('aria-label', 'Cookie consent'); + el.innerHTML = + '
Cookies · trackingGA4
' + + '

We use Google Analytics to count visits and see which editions get attention — no ads, no data resale. Analytics only loads if you accept.

' + + '
'; + el.querySelector('.ok').addEventListener('click', function () { + store('granted'); loadGA(); removeBanner(); + }); + el.querySelector('.no').addEventListener('click', function () { + store('denied'); removeBanner(); + }); + document.body.appendChild(el); + } + + // "Cookie settings" reopen link, appended to the footer bottom row on every page + function addFooterLink() { + var foot = document.querySelector('.foot-bot'); + if (!foot) return; + var a = document.createElement('span'); + a.className = 'pf-consent-link'; + a.textContent = 'COOKIE SETTINGS'; + a.addEventListener('click', function () { removeBanner(); showBanner(); }); + foot.appendChild(a); + } + + var c = choice(); + if (c === 'granted') loadGA(); + else if (c !== 'denied') showBanner(); + addFooterLink(); +})(); diff --git a/scripts/plotter.js b/scripts/plotter.js index a0d654f..914051b 100644 --- a/scripts/plotter.js +++ b/scripts/plotter.js @@ -23,8 +23,14 @@ var ink = $('ink'), inktot = $('inktot'), elapsed = $('elapsed'), total = $('total'); var bed = $('bed'), bedlabel = $('bedlabel'), selSuit = $('suit'), playBtn = $('play'), replay = $('replay'); - var FEED = 1100; // mm/min, shown in HUD + used for plot-time - var BASE = 22; // seconds for a full plot at 1x speed + var FEED = 1100; // mm/min pen-down draw speed, shown in HUD + var TRAVEL_FEED = 6600; // mm/min pen-up travel (the carriage moves much faster between strokes) + var LIFT_S = 0.10; // seconds per pen lift+lower cycle (servo up + down) + // Plot time = draw + pen-up travel + lift cycles — tuned against PlotGrid + // actuals (~0.45 s/stroke incl. overhead), not just ink-length ÷ feed. + var BASE = 30; // seconds the simulation runs at 1x watch speed — + // the machine-speed multiplier shown in the dock is + // derived from the real plot time (≈ x45–x60) var INK = '#e8351f'; var cur, len = 1, drawn = 0, painted = 0, playing = true, speed = 1, mmPerUnit = 1, totalMin = 1; var vb = { x: 0, y: 0, w: 1, h: 1 }; @@ -44,6 +50,24 @@ return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); } + // Walk the raw path string once: pen-down length, pen-up travel length, and + // lift count (each M after the first is a pen lift + travel + pen lower). + function pathStats(d) { + var draw = 0, travel = 0, lifts = 0, px = null, py = null, pen = false; + var re = /([ML])\s*(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/g, m; + while ((m = re.exec(d)) !== null) { + var x = +m[2], y = +m[3]; + if (m[1] === 'M') { + if (px !== null) { travel += Math.hypot(x - px, y - py); lifts++; } + pen = false; + } else if (px !== null) { + draw += Math.hypot(x - px, y - py); + } + px = x; py = y; + } + return { draw: draw, travel: travel, lifts: lifts }; + } + // Map a point in suit/user coordinates to canvas device pixels, matching the // SVG stage's preserveAspectRatio="xMidYMid meet" so the pen lines up. function mapX(ux) { return (tf.ox + (ux - vb.x) * tf.s) * tf.dpr; } @@ -115,7 +139,11 @@ len = ppath.getTotalLength(); if (!len || !isFinite(len)) len = 1; var md = Math.max(vb.w, vb.h); mmPerUnit = 420 / md; // longest side ≈ 420mm - totalMin = (len * mmPerUnit) / FEED; + var st = pathStats(cur.d); + totalMin = (st.draw * mmPerUnit) / FEED + + (st.travel * mmPerUnit) / TRAVEL_FEED + + (st.lifts * LIFT_S) / 60; + updateSimX(); penC.setAttribute('r', md * 0.014); penX.setAttribute('d', 'M ' + (-md * 0.032) + ' 0 H ' + (md * 0.032) + ' M 0 ' + (-md * 0.032) + ' V ' + (md * 0.032)); pen.setAttribute('opacity', '0'); @@ -159,6 +187,14 @@ if (playBtn) playBtn.textContent = 'Pause'; } + // honest speed disclosure: the sim runs the real plot in BASE/speed seconds + function updateSimX() { + var el = $('simx'); + if (!el || !totalMin) return; + var x = Math.round((totalMin * 60) / (BASE / speed)); + el.textContent = 'SIM \u2248 \u00d7' + x + ' machine speed'; + } + var last = null; function frame(t) { if (last == null) last = t; @@ -188,6 +224,7 @@ var b = e.target.closest('button'); if (!b) return; [].forEach.call(e.currentTarget.children, function (x) { x.classList.remove('on'); }); b.classList.add('on'); speed = parseFloat(b.dataset.s); + updateSimX(); }); if (selSuit) selSuit.addEventListener('change', function (e) { load(e.target.value); }); if (replay) replay.addEventListener('click', restart); diff --git a/scripts/product.js b/scripts/product.js index fd5429c..27d5bb6 100644 --- a/scripts/product.js +++ b/scripts/product.js @@ -101,7 +101,7 @@ function makePreview(s, inkHex) { var svg = $("pdSvg"), path = $("pdPath"), canvas = $("pdCanvas"), replay = $("pdReplay"); var ctx = canvas.getContext("2d"); - var BASE = 18; // seconds for a full preview plot + var BASE = 18; // seconds for a full preview plot (product page keeps a quicker cut) var ink = inkHex; var len = 1, drawn = 0, painted = 0, playing = true, last = null; var vb = { x: 0, y: 0, w: s.w || 1, h: s.h || 1 }; diff --git a/shipping.html b/shipping.html index 4b3c52b..30297e5 100644 --- a/shipping.html +++ b/shipping.html @@ -3,13 +3,17 @@ - + Shipping · PLOTFLOW* diff --git a/success.html b/success.html index 71cc782..8b86abd 100644 --- a/success.html +++ b/success.html @@ -3,13 +3,17 @@ - + Order confirmed · PLOTFLOW* diff --git a/tools/build_data.py b/tools/build_data.py index 033a173..b7f0039 100644 --- a/tools/build_data.py +++ b/tools/build_data.py @@ -22,14 +22,15 @@ "gp02": {"file":"gp-02", "name":"GP-02A", "code":"RX-78GP02", "jp":"ガンダム試作2号機", "edition":"ED. 15/25 · 11×14″", "price":"$45"}, } -# Short editorial blurb shown on each edition's product page. +# Short editorial blurb shown on each edition's product page + lore card. +# Accurate to Universal Century canon. Keep to ~6 lines so it fits the card. LORE = { - "zaku": "The workhorse of the Principality of Zeon. Mass-produced and instantly known by its single mono-eye and shoulder spike. More Zaku IIs saw combat in the One Year War than any other mobile suit.", - "guncannon":"A mid-range support unit of the Earth Federation, built around its shoulder-mounted cannons. Heavier armor traded mobility for firepower fighting alongside the RX-78.", - "bigzam": "A colossal Zeon mobile armor designed for base defense, shielded by an I-field that turned aside beam weapons. A single unit was meant to hold off an entire fleet.", - "dom": "A ground-assault suit riding hover thrusters for deceptively fast movement across open terrain. Its scattering beam gun and giant heat saber made it a frontline terror.", - "zgok": "An amphibious Zeon suit built for hit-and-run raids from the water. Rounded armor, clawed manipulators, and head-mounted mega particle guns.", - "gp02": "A Gundam Development Project unit engineered to carry and fire a tactical warhead. Heavy shielding and a massive plasma-clad shield define its silhouette.", + "zaku": "The workhorse of the Principality of Zeon — the suit that proved the mobile-suit weapon class in combat. Instantly known by its single mono-eye and shoulder spike, more Zaku IIs fought in the One Year War (U.C. 0079) than any other machine. Char Aznable's crimson command type moved at three times the normal speed, earning him the name Red Comet.", + "guncannon":"One of the Earth Federation's first mass-production mobile suits, developed under Project V alongside the RX-78 Gundam. Built around twin shoulder cannons for mid- and long-range fire support, it traded mobility for heavier armor and served aboard White Base through the One Year War.", + "bigzam": "A colossal Zeon mobile armor built for base defense, its I-field barrier turning aside beam weapons while banks of mega particle guns swept the field. A single unit was meant to hold off an entire fleet. Dozle Zabi took it into the defense of Solomon in the war's final days.", + "dom": "A Zeon ground-assault suit that skated across open terrain on thermonuclear hover jets — deceptively fast for its bulk. Armed with a scattering beam gun and a giant heat saber, its Rick Dom variants armed the Black Tri-Stars and their feared Jet Stream Attack.", + "zgok": "An amphibious Zeon suit built for hit-and-run raids from the water. Rounded armor, iron-nail claws, and head-mounted mega particle guns made it deadly at close range. Char Aznable fielded a custom command type in his signature red.", + "gp02": "A Gundam Development Project prototype engineered around a single purpose: to carry and fire a tactical nuclear warhead. Heavy shielding and a massive plasma-clad shield define its silhouette. In U.C. 0083 it was stolen by Anavel Gato — the Nightmare of Solomon.", } # order of the dropdown in the live plotter, and of the cards in the shop grid diff --git a/tools/captions.json b/tools/captions.json index e81bd81..98f550f 100644 --- a/tools/captions.json +++ b/tools/captions.json @@ -1,12 +1,12 @@ { - "_comment": "Instagram captions for the auto-poster. One entry per edition key. Edit freely. The poster appends the standard footer (link + hashtags) unless you set 'raw': true.", - "_footer": "\n\n— Plotted to order, signed & numbered.\nShop the edition → plotflow.io\n\n#penplotter #axidraw #generativeart #plotterart #gundam #mobilesuit #lineart #マシンドロー #onelinedrawing #pendrawing #machinedrawn #gunpla #universalcentury #plottertwitter #penart", + "_comment": "Instagram captions for the auto-poster. One entry per edition key. Edit freely. The poster appends the standard footer (link + hashtags) unless you set 'raw': true. Lore is accurate to Universal Century canon.", + "_footer": "\n\n— Plotted to order, signed & numbered.\nShop the edition → plotflow.io\n\n#penplotter #axidraw #generativeart #plotterart #gundam #mobilesuit #lineart #マシンドロー #pendrawing #machinedrawn #gunpla #universalcentury #plottertwitter #penart", "captions": { - "zaku": "MS-06 Zaku II — drawn by machine, one line at a time.\n\nThe workhorse of Zeon, rebuilt as a single continuous path and traced in ink by an AxiDraw plotter. No printing. Every impression is drawn.\n\nED. 09/25 · 11×14″ · $45", - "dom": "MS-09 Dom — hovering in, one stroke at a time.\n\nA ground-assault suit riding hover thrusters, redrawn as a single plotted line on Strathmore Bristol. Pen to paper, no ink-jet in sight.\n\nED. 12/25 · 11×14″ · $45", - "guncannon": "RX-77 Guncannon — firepower in fineliner.\n\nThe Federation's shoulder-cannon support unit, traced line by line in pigment ink by a pen plotter. Heavier armor, heavier linework.\n\nED. 18/25 · 11×14″ · $45", - "bigzam": "MA-08 Big Zam — a colossus, drawn by hand of machine.\n\nThe mobile armor meant to hold off a fleet, rebuilt as one continuous path and plotted in ink. Watch the I-field take shape, stroke by stroke.\n\nED. 05/25 · 11×14″ · $45", - "zgok": "MSM-07 Z'Gok — surfacing, one line at a time.\n\nZeon's amphibious raider, redrawn as a single plotted path. Clawed, rounded, and traced in ink on archival paper by an AxiDraw.\n\nED. 22/25 · 11×14″ · $45", - "gp02": "RX-78GP02 GP-02A — the heavy hitter, plotted in pen.\n\nThe Gundam Development Project's warhead carrier, rebuilt as one continuous line and drawn by machine. Massive shield, single stroke.\n\nED. 15/25 · 11×14″ · $45" + "zaku": "MS-06 ZAKU II · ザク\n\nThe Principality of Zeon's mass-production workhorse — more Zaku IIs fought in the One Year War (U.C. 0079) than any other mobile suit. The single mono-eye and shoulder spike became the silhouette of a war. Char Aznable's crimson command type earned him the name 'Red Comet.'\n\nDrawn stroke by stroke in pigment ink by an AxiDraw. Nothing printed — every impression is drawn.\n\nED. 09/25 · 11×14″", + "dom": "MS-09 DOM · ドム\n\nA Zeon ground-assault suit that skated across open terrain on thermonuclear hover jets — deceptively fast for its bulk. Armed with a scattering beam gun and a giant heat saber. Its Rick Dom cousins armed the Black Tri-Stars' feared Jet Stream Attack.\n\nDrawn by machine in ink on Strathmore Bristol, stroke by stroke.\n\nED. 12/25 · 11×14″", + "guncannon": "RX-77 GUNCANNON · ガンキャノン\n\nEarth Federation mid-range fire support, born from Project V alongside the RX-78-2 Gundam. Twin shoulder cannons and heavy torso armor traded mobility for standoff firepower aboard White Base.\n\nTraced line by line in pigment ink by a pen plotter.\n\nED. 18/25 · 11×14″", + "bigzam": "MA-08 BIG ZAM · ビグ・ザム\n\nA colossal Zeon mobile armor built to hold off an entire fleet alone — its I-field turned aside beam weapons. Dozle Zabi took it into the defense of Solomon in the closing days of the One Year War. 'If only we'd had the resources to mass-produce it…'\n\nPlotted in ink by machine, stroke by stroke.\n\nED. 05/25 · 11×14″", + "zgok": "MSM-07 Z'GOK · ズゴック\n\nZeon's amphibious raider — surfacing from the sea for hit-and-run strikes. Rounded armor, iron-nail claws, and head-mounted mega particle guns. Char Aznable fielded a custom command type in his signature red.\n\nTraced in pigment ink on archival paper by an AxiDraw.\n\nED. 22/25 · 11×14″", + "gp02": "RX-78GP02A PHYSALIS · ガンダム試作2号機\n\nA Gundam Development Project prototype engineered around a single, terrible purpose: to carry and fire an atomic warhead. Heavy shielding and a plasma-clad shield define its silhouette. In U.C. 0083 it was stolen by Anavel Gato — the 'Nightmare of Solomon.'\n\nDrawn by machine in pigment ink, line by line.\n\nED. 15/25 · 11×14″" } } diff --git a/tools/gen_concepts.py b/tools/gen_concepts.py new file mode 100644 index 0000000..eb39ffc --- /dev/null +++ b/tools/gen_concepts.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +""" +gen_concepts.py — mock up NEW post/reel design directions for review. + +Renders to mockups/ so you can thumbs-up/down before any of these graduate +into tools/gen_posts.py. Run from the repo root: python tools/gen_concepts.py + +Concepts: + A gradient — the path colored start→end to show the draw order + B blueprint — dark technical schematic, cyan on navy, grid + reg marks + C macro — extreme abstract crop of the linework, gallery-object energy + D roster — all six suits as a contact-sheet collection poster + E statement — bold typographic philosophy card +""" +import json, math, os, re +from PIL import Image, ImageDraw, ImageFont + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA = json.loads(open(os.path.join(ROOT, 'data/editions.js')).read().split('=', 1)[1].rsplit(';', 1)[0]) +SUITS = DATA['suits'] +ORDER = DATA.get('shopOrder', list(SUITS.keys())) + +OUT = os.path.join(ROOT, 'mockups') +os.makedirs(OUT, exist_ok=True) + +S = 1080; SS = 3; C = S * SS +INK = (21, 22, 15) +RED = (232, 53, 31) +PAPER = (246, 243, 236) +DIM = (143, 145, 132) +MUTE = (195, 197, 184) +NAVY = (13, 22, 38) +CYAN = (76, 201, 240) +GOLD = (242, 183, 5) +INDIGO= (43, 45, 90) +WHITE = (255, 255, 255) + +BOLD = '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf' +REG = '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf' +JP = '/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf' +def F(f, px): return ImageFont.truetype(f, int(px)) + +def parse_path(d): + subpaths, cur = [], [] + for cmd, xs, ys in re.findall(r'([ML])\s*(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)', d): + x, y = float(xs), float(ys) + if cmd == 'M': + if len(cur) > 1: subpaths.append(cur) + cur = [(x, y)] + else: cur.append((x, y)) + if len(cur) > 1: subpaths.append(cur) + return subpaths + +def bbox(sp): + xs = [p[0] for s in sp for p in s]; ys = [p[1] for s in sp for p in s] + return min(xs), min(ys), max(xs), max(ys) + +def fit(bb, region, pad_frac=0.06): + minx, miny, maxx, maxy = bb + pad = max(maxx-minx, maxy-miny) * pad_frac + minx -= pad; miny -= pad; vbw = (maxx-minx)+pad; vbh = (maxy-miny)+pad + rx, ry, rw, rh = region + sc = min(rw/vbw, rh/vbh) + ox = rx + (rw - vbw*sc)/2; oy = ry + (rh - vbh*sc)/2 + return (lambda x, y: (ox+(x-minx)*sc, oy+(y-miny)*sc)), sc + +def total_len(sp): + t = 0.0 + for s in sp: + for i in range(len(s)-1): + t += math.hypot(s[i+1][0]-s[i][0], s[i+1][1]-s[i][1]) + return t + +def lerp(a, b, t): return tuple(int(a[i]+(b[i]-a[i])*t) for i in range(3)) +def grad(stops, t): + for i in range(len(stops)-1): + p0, c0 = stops[i]; p1, c1 = stops[i+1] + if p0 <= t <= p1: + return lerp(c0, c1, (t-p0)/(p1-p0 or 1)) + return stops[-1][1] + +def draw_plain(d, sp, tf, color, w): + for s in sp: + d.line([tf(x, y) for (x, y) in s], fill=color, width=w, joint='curve') + +def brand(d, xy, color, px=14): + d.text(xy, 'PLOTFLOW*', font=F(BOLD, px*SS), fill=color) + +# ============================================================ +# A · GRADIENT — color the path start→end (proves one line) +# ============================================================ +def concept_gradient(key, dark=False): + s = SUITS[key]; sp = parse_path(s['d']); bb = bbox(sp) + bg = INK if dark else PAPER + img = Image.new('RGB', (C, C), bg); d = ImageDraw.Draw(img, 'RGBA') + tf, _ = fit(bb, (90*SS, 150*SS, 900*SS, 700*SS)) + stops = [(0.0, INDIGO), (0.5, RED), (1.0, GOLD)] + tot = total_len(sp); done = 0.0; w = max(2, 2*SS) + for s2 in sp: + for i in range(len(s2)-1): + seg = math.hypot(s2[i+1][0]-s2[i][0], s2[i+1][1]-s2[i][1]) + t = done/tot if tot else 0 + d.line([tf(*s2[i]), tf(*s2[i+1])], fill=grad(stops, t)+(255,), width=w, joint='curve') + done += seg + fg = PAPER if dark else INK + d.text((90*SS, 70*SS), s['code'], font=F(BOLD, 40*SS), fill=fg) + d.text((90*SS, 118*SS), f"{s['name']} {s.get('jp','')}", font=F(JP, 20*SS), fill=DIM) + # gradient legend + lx, ly, lw = 90*SS, C-120*SS, 380*SS + for i in range(int(lw)): + d.line([(lx+i, ly), (lx+i, ly+10*SS)], fill=grad(stops, i/lw), width=1) + d.text((lx, ly-26*SS), 'COLORED IN DRAW ORDER', font=F(BOLD, 13*SS), fill=fg) + d.text((lx, ly+18*SS), 'START', font=F(REG, 10*SS), fill=DIM) + ew = d.textlength('END', font=F(REG, 10*SS)) + d.text((lx+lw-ew, ly+18*SS), 'END', font=F(REG, 10*SS), fill=DIM) + brand(d, (C-90*SS-d.textlength('PLOTFLOW*', font=F(BOLD, 14*SS)), 70*SS), fg+(120,)) + img.resize((S, S), Image.LANCZOS).save(f'{OUT}/concept-{key}-gradient{"-dark" if dark else ""}.png') + print(f' gradient {key}{" dark" if dark else ""}') + +# ============================================================ +# B · BLUEPRINT — cyan schematic on navy, grid + reg marks +# ============================================================ +def concept_blueprint(key): + s = SUITS[key]; sp = parse_path(s['d']); bb = bbox(sp) + img = Image.new('RGB', (C, C), NAVY); d = ImageDraw.Draw(img, 'RGBA') + # grid + step = 60*SS + for x in range(0, C, step): d.line([(x, 0), (x, C)], fill=CYAN+(18,), width=1) + for y in range(0, C, step): d.line([(0, y), (C, y)], fill=CYAN+(18,), width=1) + # reg marks + m, sz = 46*SS, 18*SS + for cx, cy in [(m, m), (C-m, m), (m, C-m), (C-m, C-m)]: + d.line([cx-sz, cy, cx+sz, cy], fill=CYAN+(160,), width=2*SS) + d.line([cx, cy-sz, cx, cy+sz], fill=CYAN+(160,), width=2*SS) + tf, _ = fit(bb, (110*SS, 150*SS, 860*SS, 680*SS)) + draw_plain(d, sp, tf, CYAN+(230,), max(2, 2*SS)) + # header + d.text((60*SS, 46*SS), 'TECHNICAL BLUEPRINT', font=F(BOLD, 15*SS), fill=CYAN) + d.text((60*SS, 74*SS), f"{s['code']} · {s['name']}", font=F(REG, 13*SS), fill=CYAN+(180,)) + jw = d.textlength('マシンドロー', font=F(JP, 13*SS)) + d.text((C-60*SS-jw, 50*SS), 'マシンドロー', font=F(JP, 13*SS), fill=CYAN+(150,)) + # bottom data line + d.text((60*SS, C-70*SS), f"DRAWN BY MACHINE · {sum(len(x) for x in sp):,} VERTICES · 11×14″", font=F(REG, 12*SS), fill=CYAN+(160,)) + brand(d, (C-60*SS-d.textlength('PLOTFLOW*', font=F(BOLD, 14*SS)), C-72*SS), CYAN) + img.resize((S, S), Image.LANCZOS).save(f'{OUT}/concept-{key}-blueprint.png') + print(f' blueprint {key}') + +# ============================================================ +# C · MACRO — extreme abstract crop of the linework +# ============================================================ +def concept_macro(key): + s = SUITS[key]; sp = parse_path(s['d']); bb = bbox(sp) + minx, miny, maxx, maxy = bb + # crop to the upper-center third — dense, abstract + cw, ch = (maxx-minx), (maxy-miny) + crop = (minx+cw*0.18, miny+ch*0.02, minx+cw*0.82, miny+ch*0.40) + img = Image.new('RGB', (C, C), PAPER); d = ImageDraw.Draw(img, 'RGBA') + tf, _ = fit(crop, (-60*SS, -60*SS, C+120*SS, C+120*SS), pad_frac=0.0) # overscan/bleed + draw_plain(d, sp, tf, RED, max(3, 3*SS)) + # minimal label chip + d.rectangle([0, C-92*SS, C, C], fill=PAPER) + label = f"{s['code']} · {s['name']} " + d.text((60*SS, C-74*SS), label, font=F(BOLD, 18*SS), fill=INK) + lw = d.textlength(label, font=F(BOLD, 18*SS)) + d.text((60*SS+lw, C-73*SS), s.get('jp', ''), font=F(JP, 16*SS), fill=INK) # JP font ⇒ no tofu + d.text((60*SS, C-44*SS), 'DETAIL · DRAWN BY MACHINE', font=F(REG, 11*SS), fill=DIM) + brand(d, (C-60*SS-d.textlength('PLOTFLOW*', font=F(BOLD, 14*SS)), C-70*SS), INK+(120,)) + img.resize((S, S), Image.LANCZOS).save(f'{OUT}/concept-{key}-macro.png') + print(f' macro {key}') + +# ============================================================ +# D · ROSTER — contact sheet of all six editions +# ============================================================ +def concept_roster(): + img = Image.new('RGB', (C, C), PAPER); d = ImageDraw.Draw(img, 'RGBA') + d.text((60*SS, 56*SS), 'THE ROSTER', font=F(BOLD, 40*SS), fill=INK) + d.text((60*SS, 108*SS), '出撃 · current editions', font=F(JP, 18*SS), fill=DIM) + cols, rows = 3, 2 + gx, gy = 60*SS, 190*SS + cw = (C - 2*gx) / cols; chh = (C - gy - 90*SS) / rows + for i, key in enumerate(ORDER): + s = SUITS[key]; sp = parse_path(s['d']); bb = bbox(sp) + cx = gx + (i % cols)*cw; cy = gy + (i//cols)*chh + tf, _ = fit(bb, (cx+14*SS, cy+14*SS, cw-28*SS, chh-56*SS)) + draw_plain(d, sp, tf, RED, max(1, 1*SS)+SS) + d.text((cx+14*SS, cy+chh-40*SS), s['code'], font=F(BOLD, 15*SS), fill=INK) + d.text((cx+14*SS, cy+chh-18*SS), s['name'], font=F(REG, 11*SS), fill=DIM) + d.rectangle([0, C-54*SS, C, C], fill=RED) + d.text((60*SS, C-40*SS), 'PLOTFLOW* · DRAWN BY MACHINE · plotflow.io', font=F(BOLD, 12*SS), fill=WHITE) + img.resize((S, S), Image.LANCZOS).save(f'{OUT}/concept-roster.png') + print(' roster') + +# ============================================================ +# E · STATEMENT — typographic philosophy card +# ============================================================ +def concept_statement(key='zaku'): + s = SUITS[key]; sp = parse_path(s['d']); bb = bbox(sp) + img = Image.new('RGB', (C, C), INK); d = ImageDraw.Draw(img, 'RGBA') + tf, _ = fit(bb, (380*SS, 120*SS, 660*SS, 820*SS)) + draw_plain(d, sp, tf, RED+(45,), max(2, 2*SS)) + d.text((70*SS, 250*SS), 'NOTHING', font=F(BOLD, 92*SS), fill=PAPER) + d.text((70*SS, 345*SS), 'IS PRINTED.', font=F(BOLD, 92*SS), fill=PAPER) + d.text((70*SS, 470*SS), 'EVERY IMPRESSION', font=F(BOLD, 40*SS), fill=RED) + d.text((70*SS, 522*SS), 'IS DRAWN.', font=F(BOLD, 40*SS), fill=RED) + d.text((70*SS, 640*SS), 'マシンドロー', font=F(JP, 30*SS), fill=DIM) + d.text((70*SS, 700*SS), 'Pigment ink on Strathmore Bristol · AxiDraw pen plotter', font=F(REG, 14*SS), fill=MUTE) + brand(d, (70*SS, C-90*SS), PAPER) + img.resize((S, S), Image.LANCZOS).save(f'{OUT}/concept-statement.png') + print(' statement') + + +# ============================================================ +# F · SPECTRUM EDITION — plotter-HONEST banded gradient +# The path split into N arc-length bands, each a solid pen color. +# This is what a real multi-pen swap plot would produce (stepped, not +# smooth) — and export_spectrum_layers() writes the actual per-pen SVGs. +# ============================================================ +RAMP = [(0.0, INDIGO), (0.5, RED), (1.0, GOLD)] +def band_color(b, bands): return grad(RAMP, (b + 0.5) / max(1, bands)) + +def _ordered_segments(sp): + """Every segment in draw order, with cumulative arc length.""" + segs = []; cum = 0.0 + for pi, poly in enumerate(sp): + for i in range(len(poly) - 1): + (x0, y0), (x1, y1) = poly[i], poly[i + 1] + L = math.hypot(x1 - x0, y1 - y0) + if L <= 0: continue + segs.append((pi, x0, y0, x1, y1, cum, L)); cum += L + return segs, cum + +def concept_spectrum(key, bands=8): + s = SUITS[key]; sp = parse_path(s['d']); bb = bbox(sp) + segs, tot = _ordered_segments(sp) + img = Image.new('RGB', (C, C), PAPER); d = ImageDraw.Draw(img, 'RGBA') + tf, _ = fit(bb, (90*SS, 165*SS, 900*SS, 640*SS)) + w = max(2, 2*SS) + for (pi, x0, y0, x1, y1, cs, L) in segs: + b = min(bands - 1, int(cs / tot * bands)) if tot else 0 + d.line([tf(x0, y0), tf(x1, y1)], fill=band_color(b, bands) + (255,), width=w, joint='curve') + d.text((90*SS, 60*SS), s['code'], font=F(BOLD, 40*SS), fill=INK) + d.text((90*SS, 110*SS), f'SPECTRUM EDITION · {bands}-PEN', font=F(BOLD, 16*SS), fill=RED) + d.text((90*SS, 138*SS), f"{s['name']} {s.get('jp','')}", font=F(JP, 18*SS), fill=DIM) + # swatch legend = the pen set, in plot order + lx, ly = 90*SS, C - 150*SS + sw = (760*SS) / bands + for b in range(bands): + d.rectangle([lx + b*sw, ly, lx + b*sw + sw - 6*SS, ly + 40*SS], fill=band_color(b, bands)) + d.text((lx, ly - 28*SS), f'PLOT ORDER · PEN 01 → {bands:02d}', font=F(BOLD, 13*SS), fill=INK) + d.text((lx, ly + 52*SS), 'Each band a solid pen · swapped mid-plot · exact registration', font=F(REG, 11*SS), fill=DIM) + brand(d, (C-90*SS - d.textlength('PLOTFLOW*', font=F(BOLD, 14*SS)), 66*SS), INK+(120,)) + img.resize((S, S), Image.LANCZOS).save(f'{OUT}/concept-{key}-spectrum.png') + print(f' spectrum {key} ({bands}-pen)') + +def export_spectrum_layers(key, bands=8): + """Write plot-ready per-pen SVG layers (one color band each).""" + s = SUITS[key]; sp = parse_path(s['d']) + segs, tot = _ordered_segments(sp) + runs = {b: [] for b in range(bands)} + cur = {'band': None, 'poly': None, 'pts': None} + for (pi, x0, y0, x1, y1, cs, L) in segs: + b = min(bands - 1, int(cs / tot * bands)) if tot else 0 + start, end = (x0, y0), (x1, y1) + if cur['band'] != b or cur['poly'] != pi or cur['pts'] is None or cur['pts'][-1] != start: + cur = {'band': b, 'poly': pi, 'pts': [start, end]} + runs[b].append(cur['pts']) + else: + cur['pts'].append(end) + outdir = os.path.join(OUT, f'spectrum-{key}') + os.makedirs(outdir, exist_ok=True) + plan = [] + for b in range(bands): + col = band_color(b, bands); hh = '%02x%02x%02x' % col + dparts = ['M ' + ' L '.join(f'{x:.1f},{y:.1f}' for (x, y) in pts) for pts in runs[b]] + svg = (f"" + f"") + open(os.path.join(outdir, f'pen-{b+1:02d}_{hh}.svg'), 'w').write(svg) + plan.append(f'PEN {b+1:02d} #{hh} ({len(runs[b])} strokes)') + open(os.path.join(outdir, 'PLOT_PLAN.txt'), 'w').write( + f"SPECTRUM EDITION — {s['code']} {s['name']}\n" + f"{bands}-pen layered plot · all layers share the suit's coordinate space.\n\n" + f"Plot each layer in order, swapping to the matching pen between layers:\n\n" + + '\n'.join(plan) + + "\n\nLoad pen-NN.svg files into the AxiDraw app (or vpype/saxi) and plot in\n" + "sequence. Registration is exact, so the bands align into one flowing path.\n" + "Note: this is a stepped (banded) gradient — the honest limit of a pen.\n") + print(f' spectrum layers → {outdir}/ ({bands} pens + PLOT_PLAN.txt)') + + +if __name__ == '__main__': + print('Mocking up new concepts → mockups/') + concept_gradient('zaku') + concept_gradient('zaku', dark=True) + concept_blueprint('zaku') + concept_macro('zaku') + concept_roster() + concept_statement('zaku') + concept_spectrum('zaku', bands=8) + export_spectrum_layers('zaku', bands=8) + print('Done. Review in mockups/ and tell me which to keep.') diff --git a/tools/gen_merch.py b/tools/gen_merch.py index 52dc94c..c495911 100644 --- a/tools/gen_merch.py +++ b/tools/gen_merch.py @@ -7,6 +7,10 @@ SUITS = DATA['suits'] ORDER = DATA.get('plotterOrder', list(SUITS.keys())) +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT = os.path.join(ROOT, 'mockups', 'merch') +os.makedirs(OUT, exist_ok=True) + INK = (21, 22, 15) RED = (232, 53, 31) PAPER = (246, 243, 236) @@ -40,6 +44,16 @@ def seg_lengths(subpaths): total += L return segs, total + +def plot_minutes(sp, total_draw, mm): + """Plot time = draw + pen-up travel + lift cycles (tuned to PlotGrid actuals), + not just ink-length / feed.""" + travel = 0.0 + for a, b in zip(sp, sp[1:]): + travel += math.hypot(b[0][0]-a[-1][0], b[0][1]-a[-1][1]) + lifts = max(0, len(sp)-1) + return (total_draw*mm)/1100 + (travel*mm)/6600 + lifts*0.10/60 + def bbox(sp): xs = [p[0] for s in sp for p in s] ys = [p[1] for s in sp for p in s] @@ -116,7 +130,7 @@ def tee_back(key, variant='black'): bw = d.textlength(brand, font=f_brand) d.text((W/2 - bw/2, text_y), brand, font=f_brand, fill=RED + (160,)) - path = f'/tmp/merch-{key}-tee-back-{variant}.png' + path = f'{OUT}/merch-{key}-tee-back-{variant}.png' img.save(path, 'PNG'); print(f' {path}'); return path @@ -150,7 +164,7 @@ def tee_front(variant='black'): jw = d.textlength(jt, font=f_jp) d.text((cx - jw/2, cy + 168), jt, font=f_jp, fill=text_color + (80,)) - path = f'/tmp/merch-tee-front-{variant}.png' + path = f'{OUT}/merch-tee-front-{variant}.png' img.save(path, 'PNG'); print(f' {path}'); return path @@ -196,13 +210,17 @@ def tee_lineup(variant='black'): ctw = d.textlength(ct, font=f_code) d.text((cx + cell_w/2 - ctw/2, cy + cell_h - 50), ct, font=f_code, fill=text_color + (140,)) - # footer + # footer — JP in the JP font (Latin fonts render it as tofu), Latin below f_foot = ImageFont.truetype(REG, 24) - foot = 'マシンドロー · DRAWN BY MACHINE · EST. 2026' + f_foot_jp = ImageFont.truetype(JP, 24) + jt = 'マシンドロー' + jw = d.textlength(jt, font=f_foot_jp) + d.text((W/2 - jw/2, H - 180), jt, font=f_foot_jp, fill=text_color + (80,)) + foot = 'DRAWN BY MACHINE · EST. 2026' fw = d.textlength(foot, font=f_foot) - d.text((W/2 - fw/2, H - 140), foot, font=f_foot, fill=text_color + (80,)) + d.text((W/2 - fw/2, H - 130), foot, font=f_foot, fill=text_color + (80,)) - path = f'/tmp/merch-tee-lineup-{variant}.png' + path = f'{OUT}/merch-tee-lineup-{variant}.png' img.save(path, 'PNG'); print(f' {path}'); return path @@ -213,9 +231,10 @@ def poster(key): s = SUITS[key] sp = parse_path(s['d']); bb = bbox(sp) segs, total = seg_lengths(sp) + npts = sum(len(x) for x in sp) # vertices along the continuous path mm = 420 / max(bb[2]-bb[0], bb[3]-bb[1]) ink_m = (total*mm)/1000 - plot_min = int((total*mm)/1100) + plot_min = int(plot_minutes(sp, total, mm)) W, H = 5400, 7200 img = Image.new('RGB', (W, H), INK) @@ -275,7 +294,7 @@ def poster(key): ('EDITION', s.get('edition','').split('·')[0].strip()), ('INK', f'{ink_m:.1f}m total'), ('PLOT TIME', f'{plot_min} min'), - ('PRICE', s.get('price', '$45')), + ('VERTICES', f'{npts:,}'), ] for i, (label, val) in enumerate(data_items): x = 160 + i * col_w @@ -307,11 +326,11 @@ def poster(key): # bottom red strip d.rectangle([0, H-60, W, H], fill=RED) f_strip = ImageFont.truetype(BOLD, 24) - strip = 'PLOTFLOW* · マシンドロー · DRAWN BY MACHINE · EST. 2026 · PLOTTED IN THE U.S.A.' + strip = 'PLOTFLOW* · DRAWN BY MACHINE · EST. 2026 · PLOTTED IN THE U.S.A.' sw = d.textlength(strip, font=f_strip) d.text((W/2 - sw/2, H - 48), strip, font=f_strip, fill=WHITE) - path = f'/tmp/merch-{key}-poster.png' + path = f'{OUT}/merch-{key}-poster.png' img.save(path, 'PNG'); print(f' {path}'); return path @@ -345,7 +364,7 @@ def sticker(key): cw = d.textlength(ct, font=f_code) d.text((SZ/2 - cw/2, SZ - pad - 80), ct, font=f_code, fill=MUTE + (180,)) - path = f'/tmp/merch-{key}-sticker.png' + path = f'{OUT}/merch-{key}-sticker.png' img.save(path, 'PNG'); print(f' {path}'); return path @@ -386,7 +405,7 @@ def tote(key, variant='natural'): bw = d.textlength(brand, font=f_brand) d.text((W/2 - bw/2, bottom_y + 160), brand, font=f_brand, fill=RED + (180,)) - path = f'/tmp/merch-{key}-tote-{variant}.png' + path = f'{OUT}/merch-{key}-tote-{variant}.png' img.save(path, 'PNG'); print(f' {path}'); return path @@ -411,13 +430,17 @@ def mug_wrap(): cw = d.textlength(ct, font=f_code) d.text((x + suit_w/2 - cw/2, H - 100), ct, font=f_code, fill=DIM) - # brand centered at very bottom + # brand centered at very bottom — JP part in the JP font (avoids tofu) f_brand = ImageFont.truetype(BOLD, 22) - brand = 'PLOTFLOW* · マシンドロー' - bw = d.textlength(brand, font=f_brand) - d.text((W/2 - bw/2, H - 50), brand, font=f_brand, fill=RED + (140,)) - - path = '/tmp/merch-mug-wrap.png' + f_brand_jp = ImageFont.truetype(JP, 22) + latin, jt = 'PLOTFLOW* · ', 'マシンドロー' + lw = d.textlength(latin, font=f_brand) + jw = d.textlength(jt, font=f_brand_jp) + x0 = W/2 - (lw + jw)/2 + d.text((x0, H - 50), latin, font=f_brand, fill=RED + (140,)) + d.text((x0 + lw, H - 50), jt, font=f_brand_jp, fill=RED + (140,)) + + path = f'{OUT}/merch-mug-wrap.png' img.save(path, 'PNG'); print(f' {path}'); return path @@ -447,14 +470,150 @@ def pin_design(key): # asterisk mark draw_asterisk(d, SZ - 80, 80, 20, RED, 4) - path = f'/tmp/merch-{key}-pin.png' + path = f'{OUT}/merch-{key}-pin.png' img.save(path, 'PNG'); print(f' {path}'); return path +# ============================================================ +# PRESENTATION MOCKUPS — the print composited onto a flat garment +# silhouette (brand-consistent flat style, honest: a mockup, not a photo) +# ============================================================ + +def _tee_polygon(cx, ty, w): + """Classic tee outline (back view), width w = body width unit.""" + return [ + (cx-0.18*w, ty), # left neck + (cx-0.55*w, ty+0.05*w), # left shoulder tip + (cx-0.75*w, ty+0.40*w), # left sleeve end (outer) + (cx-0.53*w, ty+0.52*w), # left sleeve cuff (inner) + (cx-0.47*w, ty+0.40*w), # left underarm + (cx-0.47*w, ty+1.32*w), # left hem + (cx+0.47*w, ty+1.32*w), # right hem + (cx+0.47*w, ty+0.40*w), + (cx+0.53*w, ty+0.52*w), + (cx+0.75*w, ty+0.40*w), + (cx+0.55*w, ty+0.05*w), + (cx+0.18*w, ty), # right neck + ] + +def present_tee(print_path, out_name, variant, label): + """Composite a flat print file onto a tee silhouette, masked to the garment.""" + CV = 2160 + canvas = Image.new('RGB', (CV, CV), PAPER) + d = ImageDraw.Draw(canvas, 'RGBA') + + cx, ty, w = CV/2, CV*0.16, CV*0.42 + poly = _tee_polygon(cx, ty, w) + + # exact match to the print file's bg so the panel is seamless on the garment + tee_col = BLACK if variant == 'black' else WHITE + edge_col = (0, 0, 0, 60) if variant != 'black' else (255, 255, 255, 36) + + # garment layer: tee color + the print placed on the body, then masked + mask = Image.new('L', (CV, CV), 0) + ImageDraw.Draw(mask).polygon(poly, fill=255) + layer = Image.new('RGB', (CV, CV), tee_col) + pr = Image.open(print_path) + pw = int(w * 0.66); ph = int(pr.height * pw / pr.width) + pr = pr.resize((pw, ph), Image.LANCZOS) + layer.paste(pr, (int(cx - pw/2), int(ty + 0.30*w))) + canvas.paste(layer, (0, 0), mask) + + # outline + back-collar hint + d.line(poly + [poly[0]], fill=edge_col, width=3) + d.arc([cx-0.18*w, ty-0.05*w, cx+0.18*w, ty+0.09*w], 0, 180, fill=edge_col, width=4) + + # caption strip + f = ImageFont.truetype(BOLD, 34); f2 = ImageFont.truetype(REG, 24) + d.text((90, CV-150), label, font=f, fill=INK) + d.text((90, CV-100), f'{variant.upper()} TEE · SCREEN PRINT · MOCKUP', font=f2, fill=DIM) + bw2 = d.textlength('PLOTFLOW*', font=f) + d.text((CV-90-bw2, CV-150), 'PLOTFLOW*', font=f, fill=RED) + + path = f'{OUT}/mock-tee-{out_name}-{variant}.png' + canvas.save(path, 'PNG'); print(f' {path}'); return path + +def present_tote(print_path, out_name, label): + """Composite a tote print onto a flat tote-bag silhouette.""" + CV = 2160 + canvas = Image.new('RGB', (CV, CV), PAPER) + d = ImageDraw.Draw(canvas, 'RGBA') + + bw_, bh = CV*0.46, CV*0.56 + bx, by = CV/2 - bw_/2, CV*0.26 + bag_col = (240, 235, 220) # natural canvas — matches the tote print bg + edge = (0, 0, 0, 70) + + # handles + for hx in (bx + bw_*0.22, bx + bw_*0.78): + d.line([(hx, by), (hx - (bw_*0.06 if hx < CV/2 else -bw_*0.06), by - bh*0.22)], + fill=(60, 55, 45), width=26) + # bag face + print masked to it + mask = Image.new('L', (CV, CV), 0) + ImageDraw.Draw(mask).rounded_rectangle([bx, by, bx+bw_, by+bh], radius=18, fill=255) + layer = Image.new('RGB', (CV, CV), bag_col) + pr = Image.open(print_path) + pw = int(bw_ * 0.80); ph = int(pr.height * pw / pr.width) + pr = pr.resize((pw, ph), Image.LANCZOS) + layer.paste(pr, (int(CV/2 - pw/2), int(by + bh*0.10))) + canvas.paste(layer, (0, 0), mask) + d.rounded_rectangle([bx, by, bx+bw_, by+bh], radius=18, outline=edge, width=3) + + f = ImageFont.truetype(BOLD, 34); f2 = ImageFont.truetype(REG, 24) + d.text((90, CV-150), label, font=f, fill=INK) + d.text((90, CV-100), 'NATURAL CANVAS TOTE · MOCKUP', font=f2, fill=DIM) + bw2 = d.textlength('PLOTFLOW*', font=f) + d.text((CV-90-bw2, CV-150), 'PLOTFLOW*', font=f, fill=RED) + + path = f'{OUT}/mock-tote-{out_name}.png' + canvas.save(path, 'PNG'); print(f' {path}'); return path + +def present_mug(wrap_path): + """Composite the panoramic wrap onto a flat mug silhouette.""" + CV = 2160 + canvas = Image.new('RGB', (CV, CV), PAPER) + d = ImageDraw.Draw(canvas, 'RGBA') + + mw, mh = CV*0.44, CV*0.46 + mx, my = CV/2 - mw/2 - CV*0.03, CV*0.24 + edge = (0, 0, 0, 70) + + # handle behind body + hr = mh*0.30 + d.ellipse([mx+mw-hr*0.35, my+mh*0.22, mx+mw+hr*1.15, my+mh*0.22+hr*1.6], + outline=(230, 228, 222), width=int(hr*0.30)) + d.ellipse([mx+mw-hr*0.35, my+mh*0.22, mx+mw+hr*1.15, my+mh*0.22+hr*1.6], + outline=edge, width=3) + + # body + wrap band masked in (wrap shows ~40% of the full 360° art) + mask = Image.new('L', (CV, CV), 0) + ImageDraw.Draw(mask).rounded_rectangle([mx, my, mx+mw, my+mh], radius=40, fill=255) + layer = Image.new('RGB', (CV, CV), (235, 233, 228)) + wr = Image.open(wrap_path) + crop_w = int(wr.width * 0.42) + # crop stops short of the centered brand line, and trims the brand row at + # the bottom, so no half-cut text shows on the visible face + wr = wr.crop((int(wr.width*0.03), 0, int(wr.width*0.03)+crop_w, wr.height - 70)) + ph = int(mh * 0.66); pw = int(wr.width * ph / wr.height) + wr = wr.resize((pw, ph), Image.LANCZOS) + layer.paste(wr, (int(mx + mw/2 - pw/2), int(my + mh*0.17))) + canvas.paste(layer, (0, 0), mask) + d.rounded_rectangle([mx, my, mx+mw, my+mh], radius=40, outline=edge, width=3) + + f = ImageFont.truetype(BOLD, 34); f2 = ImageFont.truetype(REG, 24) + d.text((90, CV-150), 'FULL LINEUP · 11oz MUG', font=f, fill=INK) + d.text((90, CV-100), 'PANORAMIC WRAP · MOCKUP', font=f2, fill=DIM) + bw2 = d.textlength('PLOTFLOW*', font=f) + d.text((CV-90-bw2, CV-150), 'PLOTFLOW*', font=f, fill=RED) + + path = f'{OUT}/mock-mug.png' + canvas.save(path, 'PNG'); print(f' {path}'); return path + + # ============================================================ # GENERATE ALL # ============================================================ -os.chdir('/home/user/plotflow.github.io') +os.chdir(ROOT) print('=== T-SHIRT BACK PRINTS (black tee) ===') for k in ORDER: tee_back(k, 'black') @@ -486,4 +645,18 @@ def pin_design(key): print('\n=== PIN DESIGNS ===') for k in ORDER: pin_design(k) -print('\nDone! All merch designs generated.') +print('\n=== PRESENTATION MOCKUPS (design on garment) ===') +zk = SUITS['zaku'] +present_tee(f'{OUT}/merch-zaku-tee-back-black.png', 'zaku-back', 'black', + f"{zk['code']} · {zk['name'].upper()} · BACK PRINT") +present_tee(f'{OUT}/merch-zaku-tee-back-white.png', 'zaku-back', 'white', + f"{zk['code']} · {zk['name'].upper()} · BACK PRINT") +present_tee(f'{OUT}/merch-tee-lineup-black.png', 'lineup', 'black', + 'SEASON 01 LINEUP · BACK PRINT') +present_tee(f'{OUT}/merch-tee-front-black.png', 'front-logo', 'black', + 'CHEST LOGO · FRONT') +present_tote(f'{OUT}/merch-zaku-tote-natural.png', 'zaku', + f"{zk['code']} · {zk['name'].upper()}") +present_mug(f'{OUT}/merch-mug-wrap.png') + +print('\nDone! All merch designs generated → mockups/merch/') diff --git a/tools/gen_posts.py b/tools/gen_posts.py index e14b75c..72f8143 100644 --- a/tools/gen_posts.py +++ b/tools/gen_posts.py @@ -7,6 +7,9 @@ SUITS = DATA['suits'] ORDER = DATA.get('plotterOrder', list(SUITS.keys())) +OUT = 'assets/posts' +os.makedirs(OUT, exist_ok=True) + S = 1080; SS = 3; C = S * SS INK = (21, 22, 15) RED = (232, 53, 31) @@ -41,6 +44,16 @@ def seg_lengths(subpaths): total += L return segs, total + +def plot_minutes(sp, total_draw, mm): + """Plot time = draw + pen-up travel + lift cycles (tuned to PlotGrid actuals), + not just ink-length / feed.""" + travel = 0.0 + for a, b in zip(sp, sp[1:]): + travel += math.hypot(b[0][0]-a[-1][0], b[0][1]-a[-1][1]) + lifts = max(0, len(sp)-1) + return (total_draw*mm)/1100 + (travel*mm)/6600 + lifts*0.10/60 + def bbox(sp): xs = [p[0] for s in sp for p in s] ys = [p[1] for s in sp for p in s] @@ -145,7 +158,7 @@ def post_process(key, frac=0.55): d.text((C-80*SS-bw, C-80*SS), 'PLOTFLOW*', font=f_brand, fill=INK+(80,)) out = img.resize((S,S), Image.LANCZOS) - path = f'/tmp/post-{key}-process.png' + path = f'{OUT}/post-{key}-process.png' out.save(path, 'PNG'); print(f' {path}'); return path # ============================================================ @@ -188,11 +201,11 @@ def post_drop(key): f_spec = ImageFont.truetype(REG, 13*SS) d.text((80*SS, C-220*SS), 'Staedtler 0.3mm on Strathmore Bristol · 11×14″ · Signed & numbered', font=f_spec, fill=DIM) - # price + CTA - f_price = ImageFont.truetype(BOLD, 36*SS) - d.text((80*SS, C-160*SS), s.get('price','$45'), font=f_price, fill=RED) + # edition size + CTA (no price — gallery, not storefront) + f_edline = ImageFont.truetype(BOLD, 20*SS) + d.text((80*SS, C-160*SS), 'EDITION OF 25 · SIGNED & NUMBERED', font=f_edline, fill=RED) f_cta = ImageFont.truetype(BOLD, 16*SS) - d.text((80*SS, C-108*SS), 'plotflow.io', font=f_cta, fill=MUTE) + d.text((80*SS, C-116*SS), 'plotflow.io', font=f_cta, fill=MUTE) # brand top-right f_brand = ImageFont.truetype(BOLD, 14*SS) @@ -200,7 +213,7 @@ def post_drop(key): d.text((C-80*SS-bw, 60*SS), 'PLOTFLOW*', font=f_brand, fill=MUTE+(140,)) out = img.resize((S,S), Image.LANCZOS) - path = f'/tmp/post-{key}-drop.png' + path = f'{OUT}/post-{key}-drop.png' out.save(path, 'PNG'); print(f' {path}'); return path # ============================================================ @@ -252,9 +265,11 @@ def post_lore(key): d.text((200*SS, y), val, font=f_spec, fill=INK+(160,)) y += 22*SS - # price - f_price = ImageFont.truetype(BOLD, 28*SS) - d.text((90*SS, C-110*SS), s.get('price','$45'), font=f_price, fill=RED) + # signature: drawn by machine (no price — gallery, not storefront) + f_sig_jp = ImageFont.truetype(JP, 26*SS) + d.text((90*SS, C-116*SS), 'マシンドロー', font=f_sig_jp, fill=RED) + f_sig_en = ImageFont.truetype(REG, 12*SS) + d.text((90*SS, C-74*SS), 'DRAWN BY MACHINE', font=f_sig_en, fill=DIM) # brand bottom-right f_brand = ImageFont.truetype(BOLD, 16*SS) @@ -262,7 +277,7 @@ def post_lore(key): d.text((C-80*SS-bw, C-100*SS), 'PLOTFLOW*', font=f_brand, fill=INK+(70,)) out = img.resize((S,S), Image.LANCZOS) - path = f'/tmp/post-{key}-lore.png' + path = f'{OUT}/post-{key}-lore.png' out.save(path, 'PNG'); print(f' {path}'); return path # ============================================================ @@ -272,9 +287,10 @@ def post_spec(key): s = SUITS[key] sp = parse_path(s['d']); bb = bbox(sp) segs, total = seg_lengths(sp) + npts = sum(len(x) for x in sp) # vertices along the plotted path mm = 420 / max(bb[2]-bb[0], bb[3]-bb[1]) ink_m = (total*mm)/1000 - plot_min = (total*mm)/1100 + plot_min = plot_minutes(sp, total, mm) img = Image.new('RGB', (C,C), INK) d = ImageDraw.Draw(img, 'RGBA') @@ -327,7 +343,7 @@ def post_spec(key): ('EDITION', s.get('edition','').split('·')[0].strip()), ('INK', f'{ink_m:.1f}m'), ('PLOT TIME', f'{int(plot_min)}min'), - ('PRICE', s.get('price','$45')), + ('VERTICES', f'{npts:,}'), ] for i, (label, val) in enumerate(data): x = 60*SS + i*col_w @@ -343,7 +359,7 @@ def post_spec(key): specs = [ ('PAPER', 'Strathmore 300 Series Bristol, smooth'), ('PEN', 'Staedtler Triplus 0.3mm, pigment-based'), - ('PLOTTER', 'AxiDraw V3, continuous line path'), + ('PLOTTER', 'AxiDraw V3, drawn stroke by stroke'), ('FINISH', 'Inspected, signed, numbered by hand'), ] for label, val in specs: @@ -354,12 +370,12 @@ def post_spec(key): # bottom strip d.rectangle([0, C-36*SS, C, C], fill=RED) f_strip = ImageFont.truetype(BOLD, 10*SS) - strip = 'PLOTFLOW* · マシンドロー · DRAWN BY MACHINE · EST. 2026 · PLOTTED IN THE U.S.A.' + strip = 'PLOTFLOW* · DRAWN BY MACHINE · EST. 2026 · PLOTTED IN THE U.S.A.' sw = d.textlength(strip, font=f_strip) d.text((C/2-sw/2, C-28*SS), strip, font=f_strip, fill=WHITE) out = img.resize((S,S), Image.LANCZOS) - path = f'/tmp/post-{key}-spec.png' + path = f'{OUT}/post-{key}-spec.png' out.save(path, 'PNG'); print(f' {path}'); return path # ============================================================ @@ -393,7 +409,7 @@ def post_brand(variant='dark'): d.text((80*SS, 430*SS), 'マシンドロー', font=f_jp_big, fill=fg+(80,) if variant=='dark' else fg+(60,)) # body text - body = "Universal-Century mobile suits, rebuilt as continuous vector paths and traced in pigment ink on archival paper by an AxiDraw plotter. Nothing is printed. Every impression is drawn." + body = "Universal-Century mobile suits, redrawn as vector line work and traced in pigment ink on archival paper by an AxiDraw plotter. Nothing is printed. Every impression is drawn." lines = wrap(d, body, f_body, 700*SS) y = 560*SS for ln in lines: @@ -423,7 +439,7 @@ def post_brand(variant='dark'): d.text((C-80*SS-bw, C-60*SS), 'plotflow.io', font=f_brand, fill=accent+(160,)) out = img.resize((S,S), Image.LANCZOS) - path = f'/tmp/post-brand-{variant}.png' + path = f'{OUT}/post-brand-{variant}.png' out.save(path, 'PNG'); print(f' {path}'); return path diff --git a/tools/gen_reel.py b/tools/gen_reel.py index 32a897d..0ab7dd8 100644 --- a/tools/gen_reel.py +++ b/tools/gen_reel.py @@ -43,6 +43,14 @@ JP = '/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf' # ---------- parse path 'd' into polyline subpaths ---------- +def plot_minutes(sp, total_draw, mm): + """Plot time = draw + pen-up travel + lift cycles (tuned to PlotGrid actuals).""" + travel = 0.0 + for a, b in zip(sp, sp[1:]): + travel += math.hypot(b[0][0]-a[-1][0], b[0][1]-a[-1][1]) + lifts = max(0, len(sp)-1) + return (total_draw*mm)/FEED + (travel*mm)/6600 + lifts*0.10/60 + def parse_path(d): # tokens like "M 744,398" or "L 736,382" subpaths, cur = [], [] @@ -101,7 +109,7 @@ def my(y): return oy + (y-vby)*sc mm_per_unit = 420 / max(vbw, vbh) ink_total_m = (total_len * mm_per_unit) / 1000 - total_sec = (total_len * mm_per_unit) / FEED * 60 + total_sec = plot_minutes(subpaths, total_len, mm_per_unit) * 60 total_frames = dur * fps plot_end = int(total_frames * 0.80)