diff --git a/.claude/skills/newsletter-reels/SKILL.md b/.claude/skills/newsletter-reels/SKILL.md index cf8c4115..5bea2d43 100644 --- a/.claude/skills/newsletter-reels/SKILL.md +++ b/.claude/skills/newsletter-reels/SKILL.md @@ -1,12 +1,14 @@ --- name: newsletter-reels -description: Make short motion-graphics reels for a newsletter issue - one 1920x1080 reel for the issue itself and one 1080x1350 reel per article post, 20-30 s with music - five directions each built by parallel Opus workers, reviewed and picked in a local gallery, revised in rounds, scored with a stock track or the composed score, exported once. Use after newsletter-issue step 8, or when asked for reels, shorts or videos of the issue's posts. +description: Make short motion-graphics reels for a newsletter issue - one 1920x1080 reel for the issue itself and one 1080x1350 reel per article post, 20-30 s with music - five directions each built by parallel Opus workers, reviewed and picked in a local gallery, revised in rounds, scored with one of the owner's stock tracks (never the generated score), exported once. Use after newsletter-issue step 8, or when asked for reels, shorts or videos of the issue's posts. --- # Newsletter reels Six topics: `0-issue`, the newsletter's own reel (1920x1080, LinkedIn's landscape feed video), and the five article posts (1080x1350). Five reels per topic, each a different direction, built in parallel by headless Opus workers on the vendored motion engine in `engine/`. The owner reviews live HTML pages in a gallery, picks one per article, and every change after that is a round on the same worker. The mp4s are exported once, at the end. +**Music rule, no exceptions:** every reel the owner hears or receives plays one of the owner's stock tracks from the main checkout's `.music/pixabay/`. The worker's composed score is only a beat grid that the cuts and sound effects lock to: it is never played in the gallery, never offered as a choice, and never exported. `gallery.html` plays only the m1/m2 stock variants, `export.py` refuses `score`, and `stock_music.sh` builds both variants for every reel as soon as it lands (step 6). + In the commands below `$SK` is this skill's directory and `$R` is `tmp/reels//`. Scripts live in `$SK/scripts/`. ## 1. Prepare the inputs @@ -18,7 +20,9 @@ In the commands below `$SK` is this skill's directory and `$R` is `tmp/reels/ ## 2. Write the directions -Read `references/directions.md` and `references/learnings.md`. Write `$R/directions.md` in the same format: per topic a palette line and five directions `- ** Name (capability):** mechanism`. Each direction names the mechanism the film acts out, and the five per topic show off different capabilities (3D, UI simulation, data viz, kinetic type, particles, hand-made). Directions are written for the article, never reused across topics. +Read `references/directions.md` and `references/learnings.md`. Write `$R/directions.md` in the same format: per topic a palette line and five directions `- ** Name (capability):** mechanism`. Each direction names the mechanism the film acts out, and the five per topic each commit to a different primary style from the worker brief's Motion-first menu (kinetic type, HUD/technical, 3D camera fly-through, generative/organic, device in scene), so the owner compares five genuinely different films. Directions are written for the article, never reused across topics. Do not write a calm explainer direction: the worker brief's Motion-first section is the default because the owner picked it over the calm reels in four of five topics on 410. + +**Running experiment (from issue 411): base vs motion-max.** In every topic, write directions a-c on the base brief and directions d-e as the motion-max arm, by putting `motion-max` in the capability brackets, e.g. `- **2d Dive through the stack (motion-max, 3D fly-through):** ...`. `briefs.py` appends `briefs/motion-max.md` to those two: hard targets (12+ visual states, 3+ moving layers on every frame, a camera move per scene, 3+ transition kinds), cuts allowed, and the shot lists in `references/motion-references.md`. After the owner picks, record in `references/learnings.md` how many picks came from each arm. Once two issues agree, fold the winner into `worker.md` and drop the experiment from this step. Then run `python3 $SK/scripts/briefs.py `. It renders one brief per direction from `briefs/worker.md` into `$R/briefs/-.md`; the `0-issue` briefs get `briefs/issue-reel.md` appended and the 1920x1080 format. Existing briefs are kept unless you pass `--force` (`--only 3a` renders one). @@ -28,11 +32,13 @@ Explain the launch to the owner (how many workers, what each builds) and wait fo About a minute after the launch, check that the processes are alive and the logs exist before you report anything as running. +Write `$R/music-variants.txt` (step 6) before or right after the launch, then run `bash $SK/scripts/stock_music.sh ` in the background. It builds the m1 and m2 stock variants for each reel the moment its page lands, and exits when the launcher does, so no reel ever reaches the gallery with only its score. + `launch.sh` and `resume.sh` were only exercised with a stand-in command when the skill was written (the 409 workers ran from the prototype scripts). If either misbehaves on a real run, fix the script, note what broke in `references/learnings.md`, and carry on. ## 4. Gallery and picks -1. Run `python3 $SK/scripts/gallery.py ` in the background and open `http://127.0.0.1:8767/gallery.html` in the owner's browser. Always open review URLs in the browser for the owner; never only print them. +1. Check nothing else holds port 8767 first (`lsof -nP -iTCP:8767 -sTCP:LISTEN`): a previous issue's gallery server left running serves the old issue silently. Run `python3 $SK/scripts/gallery.py ` in the background and open `http://127.0.0.1:8767/gallery.html` in the owner's browser. Always open review URLs in the browser for the owner; never only print them. 2. **Gate:** the owner plays the reels and picks one per topic. Picks land in `$R/selections.json`. ## 5. Revision rounds @@ -46,13 +52,13 @@ Before you resume, explain to the owner which reels you will resume and with wha ## 6. Music 1. Stock tracks live in the main checkout's `.music/pixabay/` (gitignored). The owner downloads them; you never download music yourself. Before the run, tell the owner which moods the library is thin on, and what fits best: 1-3 minute instrumental tracks at about 118-130 bpm that hit full energy early, across a few moods (dark cinematic, upbeat house, minimal techno, playful), from the Pixabay Music licence. New tracks get analysed with `python3 $SK/scripts/analyse_tracks.py` (it skips the ones already in `analysis.json`). -2. Pick two tracks per picked reel by tempo and mood (the reel's `work//beatmap.md` names its sound), and write them to `$R/music-variants.txt`. +2. Pick two tracks for every reel, all 30, by tempo and mood (the topic's mood and the reel's `work//beatmap.md`), spreading the library so the set does not repeat one sound, and write them to `$R/music-variants.txt` as ` ` lines. Avoid the previous issue's picks for the issue reel. 3. `python3 $SK/scripts/swap_music.py ` builds `-`: the track from its first full-energy beat, cut to exactly the film's length and faded out over the last 2.5 s, the sound effects kept. A track longer than the film makes the player hold the last frame while the music plays on. `resume.sh --music` rebuilds these after every round. -4. **Gate:** the owner chooses score, m1 or m2 per reel in the gallery. +4. **Gate:** the owner chooses m1 or m2 per reel in the gallery (m1 plays by default). There is no score option anywhere: `stock_music.sh` has already built both variants for every reel, and `export.py` defaults to m1 and refuses `score`. ## 7. Export and hand over -Only when the owner says the set is final, run `python3 $SK/scripts/export.py `. It exports each pick with its chosen audio, sequentially, and copies it to `tmp/issue-/posts/-/reel.mp4` next to the post text Buffer takes (the issue reel to `posts/0-issue/`). It also copies every reel, named by topic, to `~/Desktop/newsletter--reels/`, which is where the owner uploads from. Look at one frame of each mp4, then `open` that Desktop folder for the owner. +Only when the owner says the set is final, run `python3 $SK/scripts/export.py `. It exports each pick with its chosen audio, sequentially, and copies it to `tmp/issue-/posts/-/reel.mp4` next to the post text Buffer takes (the issue reel to `posts/0-issue/`). It also copies every reel, named by topic, to `~/Desktop/newsletter--reels/`, which is where the owner uploads from. Buffer fetches the post copy through jsDelivr, which refuses anything over 20 MB, and the exports run 25-30 MB: re-encode each `posts/-/reel.mp4` to about 18 MB (`ffmpeg -c:v libx264 -preset slow -b:v 5000k -c:a aac -b:a 160k -movflags +faststart`), keeping the full-quality Desktop copy for direct uploads. `buffer-post.mjs` refuses a file over the limit. Look at one frame of each mp4, then `open` that Desktop folder for the owner. Nothing from this skill is committed: `tmp/` and `.music/` are gitignored, and the logos and tracks are other people's work borrowed for the post. diff --git a/.claude/skills/newsletter-reels/briefs/issue-reel.md b/.claude/skills/newsletter-reels/briefs/issue-reel.md index ea1e6920..629755c4 100644 --- a/.claude/skills/newsletter-reels/briefs/issue-reel.md +++ b/.claude/skills/newsletter-reels/briefs/issue-reel.md @@ -1,7 +1,7 @@ ## This reel is different: it promotes the whole issue - Format 1920x1080 (16:9), 30 fps: `python3 reel.py init {{SLUG}} --format 1920x1080`. It posts as LinkedIn's landscape feed video, so use the width: visual on one side, headline and logo on the other, like a keynote title card. -- It is the newsletter's own reel: issue #{{N}} and its five stories, then the call to action. 25-30 s, ~3-4 s per story (headline + its real logo + at most one fact from facts.md), paced for reading. +- It is the newsletter's own reel: issue #{{N}} and its five stories, then the call to action. 25-30 s, ~3-4 s per story (headline + its real logo + at most one fact from facts.md). The 409 issue reel with this skeleton reached ~25x the earlier issue posts, so keep it: each story's headline and logo stay readable, while the motion inside each story follows the Motion-first section (stamps on the beat, living detail, camera moves). - Frame 0 (thumbnail, read at feed size): the Institute logo (horizontal lockup from assets/logos) + "ML Engineering Newsletter #{{N}}" + a hook like "5 stories this week", fully formed at t=0. - The story logos are in assets/logos too. Use them as SVG vectors and transform them through their own anatomy, one into the next; no particle dissolves. Every logo must be exact at rest (render it from its SVG path; the 409 Meta logo broke when it was redrawn by hand). - End card: the Institute logo, the subscriber call to action from post.txt, "ethical.institute/newsletter". The final motion settles before the last frame; nothing is left half-finished. diff --git a/.claude/skills/newsletter-reels/briefs/motion-max.md b/.claude/skills/newsletter-reels/briefs/motion-max.md new file mode 100644 index 00000000..7dc322f0 --- /dev/null +++ b/.claude/skills/newsletter-reels/briefs/motion-max.md @@ -0,0 +1,9 @@ + +## Motion-max (experiment arm: this reel goes further than Motion-first) +This reel is in the experiment arm that tests whether even more motion wins. Read `{{SKILL}}/references/motion-references.md` before you write the beat map: those shot lists are the bar, so your film should feel like them, not like a faster explainer. +- **At least 12 distinct visual states in 25-30 s.** A state is a new composition, not the same layout with new text. List them with timestamps in `beatmap.md`. +- **At least 3 moving layers on every frame:** a foreground subject, a living mid layer (field, grid, particles, readouts) and a background or camera move. Nothing on screen is ever fully still, the end card included (a slow drift or a breathing light is enough there). +- **A camera move in every scene:** dolly, orbit, push-through, rack focus or a parallax pan. +- **Use at least 3 kinds of transition:** a hard cut on the beat, a match cut (a shape in one shot becomes a shape in the next), a masked wipe, a whip pan with motion blur, or a scale-through (the camera flies into an object to reach the next scene). +- **craft.md's "nothing cuts" rule does not apply to you.** Hard cuts on the beat are part of the vocabulary. Keep its carries for the moments where a match cut is the point. +- In REPORT.md, give your counts of states, layers and transition kinds, so the owner's pick can be compared against them. diff --git a/.claude/skills/newsletter-reels/briefs/worker.md b/.claude/skills/newsletter-reels/briefs/worker.md index 626e84c7..7444462c 100644 --- a/.claude/skills/newsletter-reels/briefs/worker.md +++ b/.claude/skills/newsletter-reels/briefs/worker.md @@ -1,6 +1,6 @@ To make a motion graphics video that shows what an incredible motion designer you are, like it's your showreel for a global expo keynote. Go all out. -You are ONE of {{COUNT}} parallel workers. Each is building one short social reel (LinkedIn/X feed, 1080x1350 portrait, 30 fps, 20-30 s, with composed music and sound effects) for one article in issue {{N}} of the ML Engineering newsletter from The Institute for Ethical AI Alignment & Safety (ethical.institute). The owner will see five reels per article, each in a radically different direction, and pick one. Earlier batches were judged "pretty bad" when they were generic, timid or too fast to read. A safe, template-looking result is the failure case. Yours must be polished and must show off a real capability. +You are ONE of {{COUNT}} parallel workers. Each is building one short social reel (LinkedIn/X feed, 1080x1350 portrait, 30 fps, 20-30 s, with sound effects; the music is one of the owner's stock tracks, swapped in after you build) for one article in issue {{N}} of the ML Engineering newsletter from The Institute for Ethical AI Alignment & Safety (ethical.institute). The owner will see five reels per article, each in a radically different direction, and pick one. Earlier batches were judged "pretty bad" when they were generic, timid or too fast to read. A safe, template-looking result is the failure case. Yours must be polished and must show off a real capability. ## Your assignment - Slug: {{SLUG}} @@ -10,21 +10,38 @@ You are ONE of {{COUNT}} parallel workers. Each is building one short social ree {{DIRECTION}} ## How to work -1. Read in full, in this order: the skill at {{ENGINE}}/ - SOCIAL.md first (it is your operating mode and overrides the keynote defaults), then ENGINE.md, craft.md, prompts.md, music.md, template/API.md. +1. Read in full, in this order: the skill at {{ENGINE}}/ - SOCIAL.md first, then ENGINE.md, craft.md, prompts.md, music.md, template/API.md. 2. Run the pipeline exactly as SOCIAL.md says, from the engine dir, with: export REEL_HOME={{REELS}}/work export REEL_OUT={{REELS}}/out and `python3 reel.py init {{SLUG}} --format 1080x1350`, run from {{ENGINE}}. There is no user sign-off: write beatmap.md and proceed. -3. Compose your own score.py for this direction (genre, tempo and mood that suit it; not the example score). -4. At least 3 review rounds on the check sheets (look at them with Read), with the SOCIAL.md checklist and the prompts.md feedback lens. Every headline held >= 1.5-2 s, key numbers 2-3 s. Captions on for sound-off viewing. No text clipping, overlaps or tiny type at phone size. +3. Compose a score.py for this direction at 118-130 bpm, but only as the beat grid your cuts and sound effects lock to. The owner never hears it: every reel ships with one of the owner's stock tracks, cut from its first full-energy beat to your film's length, so land your hits on a steady 4/4 grid and keep the sound effects carrying the accents. +4. At least 3 review rounds on the check sheets (look at them with Read), with the SOCIAL.md checklist and the prompts.md feedback lens, read through the Motion-first section. Every word on screen at least 0.7 s and readable on a phone; no text clipping, overlaps or tiny type. 5. `build` only. Do NOT export: the owner reviews the HTML page, and the mp4 is exported once at the end. ## Frame 0 and the brand -- Frame 0 is the feed thumbnail. At t=0 a viewer instantly sees the brand (the real logo) and the topic in big punchy type, fully formed, held ~0.8 s. No generic labels ("SCHEMATIC") on it. +- Frame 0 is the feed thumbnail. At t=0 a viewer instantly sees the brand (the real logo) and the topic in big punchy type, fully formed; it may start moving after 0.3 s. No generic labels ("SCHEMATIC") on it. - The logos in assets/logos/ are SVG: embed their path data as Path2D vectors in film.js, never as bitmaps, and keep them crisp and correctly proportioned at rest. - The logo transforms at least 3 times (out of the thumbnail, on the drop, back on the end card) through its OWN anatomy: its real pieces move as solid parts, its outline deforms with morphPath, strokes draw and undraw, it flips or extrudes in 3D. No particle dissolves of the logo (at most one small particle accent in the film). The reference the owner loved: Mistral's pixel-M blocks flying into the finish-line squares and the 50.4 digits, then re-stacking into the M. - End card: the brand mark next to the "ethical.institute" wordmark. +## Motion-first +This section wins over SOCIAL.md wherever the two disagree. +The calm explainer is the failure case: 5-7 scenes of 4 s, a held headline, a caption, a number, an end card. Every reel in the first runs came out like that, all alike. The references the owner loves are motion-design showreels that change every 1-2 s, fill every frame with living detail and feel like a music video, and on issue 410 he picked the motion-first reel over the calm one in four of five topics. Think "what would make a motion designer stop scrolling", not "how do I explain the article". +- **Pace:** a new visual idea every 1-2 s (half a bar to a bar at 120-128 bpm). Hard cuts on the beat are allowed and encouraged, mixed with continuous carries. A `cuts` or `holds` WARN from `check` may stand when the cut or hold is a deliberate beat: list it in REPORT.md with the reason. FAILs still get fixed. +- **Story:** one claim and at most two numbers from the article, carried by the style. No five-scene fact tour. +- **Captions:** optional; use one only where a word is the beat. +- **Decorative motion is wanted:** layered living detail (HUD furniture, fields, particles, grids, ticking readouts, light sweeps, parallax planes) is what makes these feel rich. SOCIAL.md's "no decorative motion" rule does not apply. Keep text legible over it with contrast plates, dimming or depth of field. +- **Type:** free. Giant heavy display words, mono micro-labels, words wrapped around shapes, letters resolving one by one, inverted words, mixed scale in one line. +- **Camera:** move it. Dolly, orbit, push through layers, whip pans with motion blur (blend 2-4 offset frames), rack focus. + +Your direction names a primary style; borrow from the others freely: +1. **Kinetic type / lyric video:** one giant word stamped per beat with scale overshoot, small words typing in beside it, words wrapping a ring or path, inverted highlight words, a terminal prompt typing a line with a block cursor. +2. **HUD / technical:** crop marks, hairline grids, a mono code or coordinates column, ticking timecodes, odometer digits (clipped digit columns easing in, faint vertical blur copies), callout boxes on leader lines growing out of an object, dimension arrows, line art drawn stroke by stroke with a glowing comet head (lineDashOffset on Path2D, additive dot, shadowBlur bloom). +3. **3D camera fly-through:** the subject as a three.js set (tilted UI panels, architecture, a chip, a corridor of layers) on a continuous camera spline with depth of field and whip pans; flat UI extruded into 3D slabs. +4. **Generative / organic:** noise-displaced contour-ring fields, flow fields, branching networks, swarms, with a glowing path cutting through and drifting on the beat. +5. **Device in scene:** the film playing on a screen inside a dark three.js scene (a laptop or phone on a glossy black table, lit only by its screen, with a mirror reflection and a slow camera move), cutting between full screen and the device. + ## Rules - Facts only from post.txt and facts*.md; copy figures exactly; never invent numbers, names or claims. Illustrative visuals that are not data get labelled as such. - On-screen text ASCII punctuation only (straight quotes, "-", "..."). diff --git a/.claude/skills/newsletter-reels/references/directions.md b/.claude/skills/newsletter-reels/references/directions.md index 00f744c0..e0566d19 100644 --- a/.claude/skills/newsletter-reels/references/directions.md +++ b/.claude/skills/newsletter-reels/references/directions.md @@ -4,6 +4,16 @@ Each direction names the MECHANISM the film acts out, in one line, and the five Capabilities to rotate through: force-directed or 3D graph, infinite zoom, UI simulation (terminal, Slack, GitHub PR), particles to order, blueprint draw-on, data viz as sport, oscilloscope/HUD, Swiss kinetic type, 3D terrain flythrough, leaderboard tower, isometric exploded architecture, counter avalanche, cut-paper explainer, layered parallax, one transforming object, scaling-law sculpture, mechanical sorting machine, hand-drawn notebook, infinite wall. +## Issue 410 (2026-10-25), motion-first wins + +The owner's picks, in motion-first mode unless marked: +- **1g Phone call, motion-first (kinetic type):** live call captions turned into a lyric video, words leaping out of the phone and slamming full screen on the beat, the waveform driving the type. +- **2f Dive through the containment (3D camera fly-through):** the camera dives through the nested sandbox, microVM, host and perimeter layers as a corridor; alarms on the escape, a covert cache-timing signal between two glass rooms, HUD readouts. +- **3g Word doc, motion-first (3D camera fly-through):** the contract in Word as a tilted 3D set the camera flies across, whip-panning between tracked changes, the agent pane extruding into slabs. +- **4d Manual flip-through (paper 3D, calm batch):** the 144-page manual riffling and stopping on its key pages, with comet-drawn lines and odometers. +- **5f Cog to die (3D + kinetic):** the Rust cog extruded in 3D unfolding into a GPU die the camera flies over, borrow-checker error stamps on the beat, 1,024 threads snapping into tiles. +- **0d Terminal digest (UI simulation, calm batch):** posted as the issue reel before the motion-first batch existed. + ## Issue 409 (2026-10-18), the first run Winners marked [PICKED]; the owner picked them for the mechanism, not the polish. diff --git a/.claude/skills/newsletter-reels/references/learnings.md b/.claude/skills/newsletter-reels/references/learnings.md index 036fba37..2315f0c6 100644 --- a/.claude/skills/newsletter-reels/references/learnings.md +++ b/.claude/skills/newsletter-reels/references/learnings.md @@ -2,6 +2,18 @@ Newest first. Fold a rule into the step or brief where it applies once it has held; keep here why it exists. +## Issue 410 (2026-10-25) + +- **Motion-first beats the calm explainer.** The first 30 reels all followed the same structure because SOCIAL.md hardcodes it: 5-7 scenes of 4 s, held headlines, a caption per scene, and "no decorative motion". The owner compared them against motion-design reels on TikTok that change every 1-2 s and asked why ours were so much calmer. A second batch of 12 ran with those rules switched off (1-2 s beats, one claim, decorative layers allowed, camera moves, a style menu of kinetic type, HUD, 3D fly-through, generative and device-in-scene), as six new directions plus six motion-first revisions of the picks. He picked motion-first reels in four of five topics (1g, 2f, 3g, 5f; 4d stayed). The worker brief's Motion-first section is now the default. +- **jsDelivr caps files at 20 MB.** The 410 reels exported at 25-30 MB, and Buffer rejected all three channels with "Video could not be read from its URL". jsDelivr was answering 403 "File size exceeded the configured limit of 20 MB" and cached that answer, so the URL had to be purged after re-encoding the post copies to 16-19 MB. +- **Optional menus at the bottom of a brief get ignored.** The motion references were first appended as an optional "pick 2-3" list under the hard rules, and only 2 of the 6 picks used them visibly. Rules that change the result belong in the body of the brief, stated as overrides. + +- **Never play the generated score.** The gallery defaulted to each worker's composed score and the m1/m2 stock variants were only built for picks, so the owner reviewed every reel with generated music and the first issue-reel export defaulted to `score`. The owner's rule is absolute: stock tracks only. The gallery now has no score option, export refuses it, and `stock_music.sh` builds both variants for all 30 reels as they land. +- **A stale gallery server serves the wrong issue.** Last week's gallery server was still bound to 8767, the new one died with "Address already in use", and the browser showed 409's reels. Check the port before starting. +- **The issue reel is the one that earns reach.** From Buffer's metrics: the 409 issue reel got ~56k LinkedIn impressions, about 25x the earlier issue posts, while the story reels sat around the image-post median. Only the photo-finish race held viewers (92% of impressions became views against 27-38% for the rest). Briefs now carry both points; the issue reel keeps the 409 skeleton. +- **Pull the issue reel forward.** The owner wanted to post the issue reel as soon as it existed: export it on its own (`export.py --slug `) once picked, without waiting for the story reels. +- **Workers read their brief at launch,** so a brief edited while the launcher is running reaches every worker not yet started. That is how the motion-reference techniques reached 20 of 30 reels mid-run. + ## Issue 409 (2026-10-18), first run - **Two formats.** The newsletter's own reel posts as LinkedIn's landscape feed video: 1920x1080. The five story reels stay 1080x1350 (the owner confirmed both after comparing against a real LinkedIn post). diff --git a/.claude/skills/newsletter-reels/references/motion-references.md b/.claude/skills/newsletter-reels/references/motion-references.md new file mode 100644 index 00000000..622e2f41 --- /dev/null +++ b/.claude/skills/newsletter-reels/references/motion-references.md @@ -0,0 +1,29 @@ +# Motion references + +The motion-design reels the owner pointed at on 2026-10-25 (two from @motionlovee on TikTok, with captions claiming a model made them). They are the bar for the Motion-max arm. Shot lists are inferred from frames captured 0.5 s apart, so exact easing and beat sync are not known. + +## Reference 1: AI-doom lyric video (38 s) +The film plays on a dark laptop on a glossy black table, lit only by its screen, with a mirror reflection. Near-black slate ground, one hot orange accent with bloom, white heavy grotesk words, small mono labels, grey hairline linework. HUD furniture in every shot: corner crop marks, a hairline grid, a code or coordinates column, tiny readouts. A new line every 1-2 s, cut on the beat. + +1. 0-1 s: a hand taps the laptop and the screen wakes (the hook). +2. 1-5 s: a construction grid with a circle and radial guides; an orange glowing rectangle is measured with dimension arrows; line art builds stroke by stroke into a figure while matching code types in on the left. +3. 5-10 s: one giant word stamped full width; small words type in one by one beside it; an orange comet head traces each stroke as it draws. +4. 10-14 s: an orange line chart draws on, then the camera flies through it. +5. 14-22 s: a topographic field of wobbly concentric rings with a glowing orange path through it; a word resolves letter by letter; words rotate 90 degrees and wrap around the ring centre. +6. 22-26 s: a terminal prompt types a full sentence with an orange block cursor over the pulsing rings; some words flip to inverted orange; then hard cuts to full-screen stamps, one a number in glowing mono whose last digit is caught mid-roll like an odometer. +7. 25-29 s: a phrase beside a branching orange lightning network; a word whose O's are drawn as concentric rings. +8. 29-31 s: a wireframe 3D corridor fly-through with words set into its walls. +9. 31-38 s: a glitched mask on a datamoshed ground, then a hatched 3D creature with orange eyes and HUD callout boxes on leader lines pointing at kinetic words, with mono timecodes. It holds on that composition. + +Count: about 15 distinct states in 38 s, never fewer than 3 moving layers, transitions mixing hard cuts, draw-on reveals and camera fly-throughs. + +## Reference 2: editor fly-through (18 s) +The same laptop framing. On screen a virtual camera flies through a 3D-tilted motion-design editor: dark UI, a white artboard, one orange accent, a strip of coloured layer bars, heavy motion blur and shallow depth of field. + +1. 0-2 s: a small voxel mascot drops onto the tilted artboard. +2. 2-4 s: it scribbles a mark that resolves into the brand logo. +3. 4-8 s: the camera pulls back and banks across the editor panels; a "made with" frame appears with fat 3D ribbon swooshes. +4. 8-14 s: the mascot runs along the timeline while the coloured layer bars extrude into 3D slabs it runs on, with whip pans and blur. +5. 14-18 s: the logo blooms over a starfield and settles on an end card. + +What carries over: UI treated as a 3D set, a character or object leading the eye through it, a logo resolved out of a scribble, whip pans carrying blur, flat UI extruded into slabs. diff --git a/.claude/skills/newsletter-reels/scripts/briefs.py b/.claude/skills/newsletter-reels/scripts/briefs.py index 4fefb50a..d15fb044 100644 --- a/.claude/skills/newsletter-reels/scripts/briefs.py +++ b/.claude/skills/newsletter-reels/scripts/briefs.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Render one worker brief per direction in tmp/reels//directions.md into tmp/reels//briefs/-.md. +"""Render one worker brief per direction in tmp/reels//directions.md into tmp/reels//briefs/-.md. -usage: briefs.py [--only ...] [--force] +usage: briefs.py [--only ...] [--force] Topic 0 (the issue reel) gets briefs/issue-reel.md appended and the 1920x1080 format; the stories stay 1080x1350. +Directions whose name carries "(motion-max" get briefs/motion-max.md appended (the experiment arm). """ import argparse @@ -14,7 +15,7 @@ SKILL = Path(__file__).resolve().parent.parent TOPIC = re.compile(r"^## (\d)-(\S+)\s*(?:\((.*)\))?\s*$") -ITEM = re.compile(r"^- (?:\[PICKED\] )?\*\*(\d)([a-e]) (.+?)\*\*\s*(.*)$") +ITEM = re.compile(r"^- (?:\[PICKED\] )?\*\*(\d)([a-z]) (.+?)\*\*\s*(.*)$") def parse(text: str): @@ -42,6 +43,7 @@ def main() -> None: sys.exit(f"no directions found in {directions}: expected lines like '- **1a Name (capability):** mechanism'") worker = (SKILL / "briefs" / "worker.md").read_text() issue_extra = (SKILL / "briefs" / "issue-reel.md").read_text() + max_extra = (SKILL / "briefs" / "motion-max.md").read_text() out = root / "briefs" out.mkdir(parents=True, exist_ok=True) written = 0 @@ -62,8 +64,10 @@ def main() -> None: text = worker if n == "0": text = text.replace("--format 1080x1350", "--format 1920x1080") + issue_extra + if "(motion-max" in name.lower(): + text += max_extra for key, value in {"{{SLUG}}": slug, "{{TOPIC}}": topic_dir, "{{DIRECTION}}": direction, "{{REELS}}": str(root), - "{{ENGINE}}": str(engine_dir()), "{{N}}": str(args.issue), "{{COUNT}}": str(len(items))}.items(): + "{{ENGINE}}": str(engine_dir()), "{{N}}": str(args.issue), "{{COUNT}}": str(len(items)), "{{SKILL}}": str(SKILL)}.items(): text = text.replace(key, value) if "{{" in text: sys.exit(f"{path.name}: unfilled placeholder {re.search(r'{{[A-Z]+}}', text).group(0)}") diff --git a/.claude/skills/newsletter-reels/scripts/export.py b/.claude/skills/newsletter-reels/scripts/export.py index 726e635b..64e9c715 100755 --- a/.claude/skills/newsletter-reels/scripts/export.py +++ b/.claude/skills/newsletter-reels/scripts/export.py @@ -63,8 +63,11 @@ def main() -> None: export_out.mkdir(parents=True, exist_ok=True) desktop.mkdir(parents=True, exist_ok=True) for topic, slug in picks: - choice = audio.get(topic, "score") - export_slug = slug if choice == "score" else f"{slug}-{choice}" + # The owner's rule: always one of the stock tracks, never the generated score. + choice = audio.get(topic, "m1") + if choice == "score": + raise SystemExit(f"{slug}: the generated score is never exported; choose m1 or m2") + export_slug = f"{slug}-{choice}" post_dirs = sorted(issue_posts.glob(f"{topic}-*")) if issue_posts.exists() else [] destination = export_out / f"{export_slug}.mp4" if not args.output_dir and topic == "0" and issue_posts.exists(): diff --git a/.claude/skills/newsletter-reels/scripts/gallery.html b/.claude/skills/newsletter-reels/scripts/gallery.html index 66690ced..1a8c20d1 100644 --- a/.claude/skills/newsletter-reels/scripts/gallery.html +++ b/.claude/skills/newsletter-reels/scripts/gallery.html @@ -15,7 +15,7 @@ .box{display:flex;flex-direction:column;align-items:center;gap:10px}iframe{border:0;width:65.6vh;max-width:92vw;aspect-ratio:var(--w,4)/var(--h,5);background:#000;border-radius:8px} .bar{display:flex;gap:8px;align-items:center}.bar>button{padding:8px 14px;border-radius:8px;border:1px solid #555;background:#222;color:#eee;cursor:pointer}#cap{color:#ccc;min-width:220px;text-align:center} -

Newsletter reels

Pick one reel per topic and the row collapses to it, with its audio options (score / m1 / m2, saved). "Show all 5" reopens the row. Click a reel to play it with sound; Esc closes, Space pauses.

+

Newsletter reels

Pick one reel per topic and the row collapses to it, with its two stock-track options (m1 / m2, saved; the generated score is never played). "Show all 5" reopens the row. Click a reel to play it with sound; Esc closes, Space pauses.

@@ -25,11 +25,11 @@

Newsletter reels

Pick one reel per topic and $('title').textContent=`Issue ${ISSUE} reels`; document.title=`${ISSUE} reels`; async function refresh(){ const r=await (await fetch('/list')).json(); have=new Set(r.html); music=new Set(r.music||[]); formats=r.formats||{}; S=r.selections||{}; S.audio=S.audio||{}; try{ (await (await fetch('music-variants.txt')).text()).trim().split('\n').forEach(l=>{const [s,g,f]=l.split(' ');TR[s+'-'+g]=f.replace(/-\d+\.mp3$/,'');}); }catch(e){} draw(); } -const aud=t=>S.audio[t]||'score'; +const aud=t=>S.audio[t]||'m1'; function dims(slug){ const f=formats[slug]||formats[slug.replace(/-m\d+$/,'')]||'1080x1350', m=f.match(/^(\d+)x(\d+)$/); return m?[Number(m[1]),Number(m[2]),f]:[4,5,f]; } function ratio(slug){ const [w,h]=dims(slug); return `--w:${w};--h:${h}`; } function fitFrame(fr,w,h){ fr.style.width=Math.min(innerWidth*.92,innerHeight*.82*w/h)+'px'; fr.style.setProperty('--w',w); fr.style.setProperty('--h',h); fr.dataset.w=w; fr.dataset.h=h; } -function audButtons(t){ const pk=S[t]; return [['score','score']].concat(['m1','m2'].filter(m=>music.has(pk+'-'+m)).map(m=>[m,m+': '+(TR[pk+'-'+m]||'').split('-').slice(0,3).join(' ')])) +function audButtons(t){ const pk=S[t]; return [].concat(['m1','m2'].filter(m=>music.has(pk+'-'+m)).map(m=>[m,m+': '+(TR[pk+'-'+m]||'').split('-').slice(0,3).join(' ')])) .map(([k,l])=>``).join(''); } function card(i,collapsed){ const ok=have.has(i.slug), t=i.topic, isSel=S[t]===i.slug; return `

`+(ok?`
▶ Play with audio
`:`
rendering...
`)+ @@ -41,11 +41,12 @@

Newsletter reels

Pick one reel per topic and async function save(){ await fetch('/select',{method:'POST',body:JSON.stringify(S)}); } async function pick(slug){ const t=item(slug).topic; if(S[t]===slug){ delete S[t]; } else { S[t]=slug; open_[t]=false; } await save(); draw(); } async function setAud(t,k){ S.audio[t]=k; await save(); draw(); if(cur&&item(cur).topic===t&&S[t]===cur) play(cur); } -function play(slug){ cur=slug; const t=item(slug).topic, a=S[t]===slug?aud(t):'score', fr=$('fr'); +function playButtons(slug,a){ const t=item(slug).topic; return ['m1','m2'].filter(m=>music.has(slug+'-'+m)).map(m=>``).join(''); } +function play(slug,a0){ cur=slug; const t=item(slug).topic, a=a0||(S[t]===slug?aud(t):'m1'), fr=$('fr'); fr.onload=()=>{ try{ fr.contentWindow.addEventListener('keydown',e=>{ if(e.key==='Escape') close_(); }); const b=fr.contentDocument.getElementById('start'); b&&b.click(); fr.contentWindow.focus(); }catch(e){} }; - const playSlug=slug+(a==='score'?'':'-'+a), [w,h,format]=dims(playSlug); fitFrame(fr,w,h); + const playSlug=slug+'-'+a, [w,h,format]=dims(playSlug); fitFrame(fr,w,h); fr.src=`out/${playSlug}.html?v=${V()}`; $('modal').classList.add('on'); - $('cap').textContent=`${short(slug)} | ${item(slug).name} | ${format}${a!=='score'?' | '+a:''}`; $('pa').innerHTML=S[t]===slug?audButtons(t):''; } + $('cap').textContent=`${short(slug)} | ${item(slug).name} | ${format}${' | '+a}`; $('pa').innerHTML=playButtons(slug,a); } function step(d){ const list=ITEMS.filter(i=>have.has(i.slug)&&(!S[i.topic]||open_[i.topic]||S[i.topic]===i.slug)).map(i=>i.slug); let k=list.indexOf(cur); play(list[(k+d+list.length)%list.length]); } function close_(){ $('fr').src='about:blank'; $('modal').classList.remove('on'); cur=null; refresh(); } $('close').onclick=close_; $('prev').onclick=()=>step(-1); $('next').onclick=()=>step(1); diff --git a/.claude/skills/newsletter-reels/scripts/stock_music.sh b/.claude/skills/newsletter-reels/scripts/stock_music.sh new file mode 100755 index 00000000..1a9a3f9f --- /dev/null +++ b/.claude/skills/newsletter-reels/scripts/stock_music.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Build the m1/m2 stock-track variants for every reel as soon as its page lands, +# so no reel ever reaches the gallery with only its generated score. +# Usage: stock_music.sh (reads $R/music-variants.txt: " ") +set -u +issue=$1 +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +root=$(git rev-parse --show-toplevel) +main=$(cd "$(git rev-parse --git-common-dir)/.." && pwd) +reels=${REEL_ROOT:-$root/tmp/reels}/$issue +music=$main/.music/pixabay +while true; do + while read -r slug tag track; do + [ -n "$slug" ] && [ -f "$reels/out/$slug.html" ] || continue + [ -f "$reels/out/$slug-$tag.html" ] && continue + if python3 "$script_dir/swap_music.py" "$issue" "$slug" "$tag" "$music/$track" < /dev/null > "$reels/logs/swap-$slug-$tag.log" 2>&1; then + echo "music ok $slug $tag" + else + echo "music FAIL $slug $tag (see logs/swap-$slug-$tag.log)" + fi + done < "$reels/music-variants.txt" + pgrep -f "launch.sh --controller $issue" > /dev/null || { echo "launcher done; $(ls "$reels"/out/*-m[12].html 2>/dev/null | wc -l | tr -d ' ') stock variants built"; exit 0; } + sleep 30 +done diff --git a/scripts/newsletter/buffer-post.mjs b/scripts/newsletter/buffer-post.mjs index 9dcc702f..2b4104c1 100644 --- a/scripts/newsletter/buffer-post.mjs +++ b/scripts/newsletter/buffer-post.mjs @@ -29,7 +29,7 @@ */ import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -181,6 +181,7 @@ async function main() { // Best format first, not whatever readdir hands back: a folder holding both a walk and its // GIF fallback would otherwise post the fallback, purely because "gif" sorts before "mp4". const mediaOrder = [ + 'reel.mp4', 'image.mp4', 'image.gif', 'image.png', @@ -190,6 +191,13 @@ async function main() { ]; const present = new Set(readdirSync(dir)); const media = mediaOrder.find((name) => present.has(name)); + // jsDelivr refuses files over 20 MB with a 403, which Buffer reports only as "Video could not + // be read from its URL", and it caches that 403 for the path. Fail before pushing anything. + if (media && statSync(path.join(dir, media)).size >= 20_000_000) { + throw new Error( + `${slugDir}/${media} is over jsDelivr's 20 MB limit; re-encode it smaller first (e.g. ffmpeg -b:v 5000k)`, + ); + } const wanted = options.channel ? String(options.channel).split(',') : ['linkedin']; const available = await channels();