Shorts motion skill

Add on-brand motion graphics and retention pattern-interrupts to a talking-head short — cue-anchored stats, step lists, tier boards and lower-thirds, drawn per frame, plus zoom interrupts that plug retention leaks.

by timscheuerai·MIT license·★ 37 Stars on the repo·GitHub ↗

Use now

Inside this skill

SKILL.md · the author's own words
# shorts-motion

Stage 4 of shorts-edit. **Scout → Plan → Render → Verify.** Never skip the scout.

The pipeline already composites a **per-frame RGBA sequence** for captions, which means it
is *already a motion-graphics engine*: anything drawable in PIL can be animated at frame
resolution with **no extra ffmpeg input and no extra encode**.

---

## The three rules that make a graphic land

**1. ANCHOR TO THE WORDS, NEVER TO A TIMESTAMP.**

Hand-typed times failed catastrophically and invisibly: six of eight graphics were out of
sync and one revealed the **"F" tier grade 5.7 seconds before he said it**. In a tier list

Word-for-word excerpt, not a result. Read the full SKILL.md ↓

Files of Shorts motion

timscheuerai/main1 file shown
SKILL.md
Show the full text223 lines

shorts-motion

Stage 4 of shorts-edit. Scout → Plan → Render → Verify. Never skip the scout.

The pipeline already composites a per-frame RGBA sequence for captions, which means it is already a motion-graphics engine: anything drawable in PIL can be animated at frame resolution with no extra ffmpeg input and no extra encode.


The three rules that make a graphic land

1. ANCHOR TO THE WORDS, NEVER TO A TIMESTAMP.

Hand-typed times failed catastrophically and invisibly: six of eight graphics were out of sync and one revealed the "F" tier grade 5.7 seconds before he said it. In a tier list the grade IS the punchline. Every frame was individually correct; nothing flagged it.

The plan names a phrase, not a time. resolve_cue() finds when it is spoken and fires the payload — the grade filling, the number landing — exactly on the word.

{ "cue": "definitely f tier", "grade": "F", "label": "Instagram / TikTok" }

2. OVERLAY PANELS, NOT FULL-SCREEN CARDS.

A full-screen card doesn't add a graphic — it removes the presenter, and on a talking-head short the face is what carries it. Panels sit in the lower third; he stays on camera. Full-screen is reserved for a genuine chapter break or a hook.

3. THE MOTION MUST TRACK THE SPEECH.

A card that fades in is a slide. A number that counts up and lands on the word, a list that builds as he names each item, a tier chip that fills the instant he says "S tier" — those feel authored. Everything else feels pasted on.


Scout first — do not invent moments

python $S/motion_scout.py "<clip>"

It surfaces where a graphic would actually earn its place: hard numbers, enumerations, rankings, named tools. On the reference session it found one clip was a tier list (8 verdicts), another a 5-step system with a 0→300 users target, another carrying 14% / 11% / 0.5% reply rates. Content that was invisible to a blind pass.

Copy is editorial, never generated. Auto-deriving it from the transcript produced headlines like "step number five is actually relaunching your saas every two weeks with a launch". A bad graphic is worse than none. A clip with no plan entry simply gets none.

Primitives (motion.py)

kind use for motion
stat a hard number counts up and lands on the word
steps an enumeration rows build one at a time; the live row goes bold + emerald
tier a ranking chips greyed, the called grade fills emerald and pops
label anything else a lower-third chip — the lightest touch
logo naming a product brand mark parks just above the caption band
people real accounts profile cards, follower counts counting up
chips a list of sources small title-only cards landing left to right
leads a lead table LinkedIn rows stream, then the scores land on a second cue
flow a routing decision lanes greyed; each fills, and its wire draws, on its own cue

Everything except stat/table hangs off a y=470 baseline, which stops 29px clear of the caption band. Panels built that way need no caption lift and no caption hide — the captions just keep running. hide_frames had been applied to every mid-frame panel, which cost the Lovable clip 13 of its 73 seconds of captions to a collision the geometry ruled out.

Nested cues — when one panel spans several beats

A graphic that stays up across a long beat (the routing board: tier 1 at 28.0s, tier 2 at 30.1s, tier 3 at 34.3s) cannot stagger its rows off a frame counter — the stagger drifts the moment the speech does, which is the same class of failure as a hand-typed timestamp. Give each row its own cue; resolve_plan() resolves it to _at, in frames relative to the panel's window.

{"kind": "flow", "cue": "hand over all the", "dur": 12.6, "lanes": [
  {"cue": "tier one leads", "badge": "TIER 1", "to": "Sales team", "focal": true},
  {"cue": "tier two",       "badge": "TIER 2", "to": "Sequence", "tiles": ["...linkedin.png"]}
]}

A <name>_cue key on the graphic itself resolves the same way, to _at_<name>. That is how the lead table's grades wait for "score them against their ICP" instead of firing on the panel's own entrance four seconds earlier.

A primitive's payload is not always 0.7s after its entrance

LEAD/PAYLOAD encode one choreography: the panel arrives, then the number lands on the word. logo and chips have nothing that lands later — the mark appearing IS the payload. verify_motion.py assumed the PAYLOAD model for every kind, so holding a logo to "entrance + 0.7s == the word" pushed its entrance 0.7s ahead of the word. The Lovable mark sat on screen most of a second before he said "Lovable", and every run reported ok.

A verifier that models one primitive's choreography and applies it to all of them certifies the bug. ENTRANCE_IS_PAYLOAD in verify_motion.py is the per-kind offset; multi-lane boards skip the single-payload check and verify each lane's own cue instead.

New primitives must also be added to brand_lint.py's render list. Three shipped outside it once, which meant the one-green rule was enforced on the four oldest renderers and silently not on the newest.

The tier board (tierboard.py)

A different shape: one persistent, cumulative board, not eight panels flashing in and out. The hook shows the finished board with the lane contents blurred (tease the payoff, don't give it), then each channel drops onto its row as he says the grade. The viewer watches it fill in. That is the tier-list format.

It occupies the lower ~40%, so the captions lift to 60% height for exactly those frames rather than being hidden for 100 seconds.

A panel and a caption cannot share a row

Captions sit at 0.78H (y≈499). A stat panel starts at y=446, a steps panel at y=422. So a caption lands inside the panel, and white type on a white surface is invisible at full opacity.

The tier board already lifted the captions to 0.60H. Nothing lifted them for the other primitives, and it shipped — because a caption over a half-faded panel is still legible, so it looks fine in exactly the frames you happen to spot-check.

Any frame carrying a stat or steps panel lifts the captions to 0.60H. label is exempt: its chip sits at 0.86H, ~40px clear of the caption band.

build.py resolves the plan before caption_frames() and unions those windows into raise_frames. Order matters: the captions have to know about the panels before they draw.

label() accepts a kicker and does not draw it

The chip is one line by design; a kicker row would make it a panel. The argument stays in the signature so every primitive takes the same plan shape — but it is silently dropped, and that silence once cost a whole batch its step numbers. If you want the kicker seen, put it in text: "01 · Build in public".

Brand — do not invent values

From gtm-production/brand/BRAND.md:

surface #ffffff · ink #0a0a0a · muted #71717a · line #e4e4e7 · accent #10b981 (ONE dot, sparingly)
font-sans Helvetica Neue (the same face as the captions) · radius 6/10/14/20
"everything is ink, surface, and line" — no shadows, no gradients.

Retention: the 2026 five-second rule

Any 5-second block with no cut, zoom, text or sound change is a retention leak.

The reference clip failed badly: 97 of 107 seconds (91%) sat in a dead block, the worst a 16.8-second stretch. All the cuts and graphics clustered at beat boundaries, and beats run 11–17s.

Fix: automatic pattern interrupts. plan_chunks() splits long beats into ≤4.8s chunks with alternating framing — a snap change of camera mid-sentence. It is not a cut; the audio runs straight through. 18 interrupts, leak 91% → 0%.

Two things this depends on:

  • Fades only at real cut edges. A 30ms fade at a framing split dips the voice audibly. Verified: 0 discontinuities across all 18 internal joins.
  • Use ceil for the chunk count. Floor-plus-fudge left 5.7s chunks — still a leak.

A 3px drawbox progress bar is available (--progress) but is off by default — it reads as UI chrome bolted onto the frame. Cheap is not the same as good.


THE RENDERING TRAP: PIL alpha does not composite

ImageDraw with a partial-alpha fill OVERWRITES pixels. A chip fading in therefore punches a transparent hole straight through the white panel beneath it, and the video shows through — the selected tier chip rendered as a black box instead of a green one.

Draw everything at full opacity on its own layer, then fade the layer. Never draw with a variable alpha. To animate a colour, LERP the colour — not the alpha.

Text behind the subject

The "he's standing in front of the word" look. behind.py. Composite order per frame:

video  ->  big text  ->  the person, cut back out on top  ->  captions + board

Two things make it work:

  1. It stays inside ONE encode. The naive way is a second pass (render, re-read, segment, re-encode) — and a second encode measurably softens the image. Instead the person is baked into the same per-frame RGBA overlay the captions use, cut from the raw source frames that ffmpeg is already decoding. The cut-out lands on top of itself: no seam.
  2. Any chunk carrying a behind-text is forced to wide. A zoomed chunk is scaled by zoompan inside ffmpeg, and re-deriving that transform in PIL would never match pixel-for-pixel — you would get a soft double-edge around him.

Layer order is the whole trick. Compositing the person LAST put his shoulder on top of the tier board and clipped the lanes. The person goes UNDER the UI: word, then him, then captions and board.

Segmentation: rembg (u2netp), ~0.2s/frame. Use it on 2-4 hero moments, not the clip.

"behind": [{"cue": "for sure s tier", "text": "S TIER", "dur": 1.9}]

Verify

python $S/verify_motion.py "<clip>"

Every graphic fires within 0.2s of its cue; no typo'd cue silently dropping a graphic; no overlapping panels; verdicts in ascending order.

1---
2name: shorts-motion
3description: Add on-brand motion graphics and retention pattern-interrupts to a talking-head short — cue-anchored stats, step lists, tier boards and lower-thirds, drawn per frame, plus zoom interrupts that plug retention leaks. Use when asked to add motion graphics, animations, overlays, b-roll cards, a tier list, or to improve a video's retention/pacing.
4---
5 
6# shorts-motion
7 
8Stage 4 of shorts-edit. **Scout → Plan → Render → Verify.** Never skip the scout.
9 
10The pipeline already composites a **per-frame RGBA sequence** for captions, which means it
11is *already a motion-graphics engine*: anything drawable in PIL can be animated at frame
12resolution with **no extra ffmpeg input and no extra encode**.
13 
14---
15 
16## The three rules that make a graphic land
17 
18**1. ANCHOR TO THE WORDS, NEVER TO A TIMESTAMP.**
19 
20Hand-typed times failed catastrophically and invisibly: six of eight graphics were out of
21sync and one revealed the **"F" tier grade 5.7 seconds before he said it**. In a tier list
22the grade IS the punchline. Every frame was individually correct; nothing flagged it.
23 
24The plan names a **phrase**, not a time. `resolve_cue()` finds when it is spoken and fires
25the payload — the grade filling, the number landing — exactly on the word.
26 
27```json
28{ "cue": "definitely f tier", "grade": "F", "label": "Instagram / TikTok" }
29```
30 
31**2. OVERLAY PANELS, NOT FULL-SCREEN CARDS.**
32 
33A full-screen card doesn't add a graphic — it *removes the presenter*, and on a talking-head
34short the face is what carries it. Panels sit in the lower third; he stays on camera.
35Full-screen is reserved for a genuine chapter break or a hook.
36 
37**3. THE MOTION MUST TRACK THE SPEECH.**
38 
39A card that fades in is a slide. A number that counts up and *lands* on the word, a list
40that builds as he names each item, a tier chip that fills the instant he says "S tier" —
41those feel authored. Everything else feels pasted on.
42 
43---
44 
45## Scout first — do not invent moments
46 
47```bash
48python $S/motion_scout.py "<clip>"
49```
50 
51It surfaces where a graphic would actually *earn* its place: hard numbers, enumerations,
52rankings, named tools. On the reference session it found one clip was a **tier list**
53(8 verdicts), another a **5-step system with a 0→300 users target**, another carrying
54**14% / 11% / 0.5% reply rates**. Content that was invisible to a blind pass.
55 
56**Copy is editorial, never generated.** Auto-deriving it from the transcript produced
57headlines like *"step number five is actually relaunching your saas every two weeks with a
58launch"*. A bad graphic is worse than none. A clip with no plan entry simply gets none.
59 
60## Primitives (`motion.py`)
61 
62| kind | use for | motion |
63|---|---|---|
64| `stat` | a hard number | counts up and lands on the word |
65| `steps` | an enumeration | rows build one at a time; the live row goes bold + emerald |
66| `tier` | a ranking | chips greyed, the called grade fills emerald and pops |
67| `label` | anything else | a lower-third chip — the lightest touch |
68| `logo` | naming a product | brand mark parks just above the caption band |
69| `people` | real accounts | profile cards, follower counts counting up |
70| `chips` | a list of sources | small title-only cards landing left to right |
71| `leads` | a lead table | LinkedIn rows stream, then the scores land on a second cue |
72| `flow` | a routing decision | lanes greyed; each fills, and its wire draws, on its own cue |
73 
74Everything except `stat`/`table` hangs off a **y=470 baseline**, which stops 29px clear of
75the caption band. Panels built that way need no caption lift and no caption hide — the
76captions just keep running. `hide_frames` had been applied to every mid-frame panel, which
77cost the Lovable clip 13 of its 73 seconds of captions to a collision the geometry ruled
78out.
79 
80## Nested cues — when one panel spans several beats
81 
82A graphic that stays up across a long beat (the routing board: tier 1 at 28.0s, tier 2 at
8330.1s, tier 3 at 34.3s) cannot stagger its rows off a frame counter — the stagger drifts
84the moment the speech does, which is the same class of failure as a hand-typed timestamp.
85Give each row its own `cue`; `resolve_plan()` resolves it to `_at`, in frames relative to
86the panel's window.
87 
88```json
89{"kind": "flow", "cue": "hand over all the", "dur": 12.6, "lanes": [
90 {"cue": "tier one leads", "badge": "TIER 1", "to": "Sales team", "focal": true},
91 {"cue": "tier two", "badge": "TIER 2", "to": "Sequence", "tiles": ["...linkedin.png"]}
92]}
93```
94 
95A `<name>_cue` key on the graphic itself resolves the same way, to `_at_<name>`. That is
96how the lead table's grades wait for "score them against their ICP" instead of firing on
97the panel's own entrance four seconds earlier.
98 
99## A primitive's payload is not always 0.7s after its entrance
100 
101`LEAD`/`PAYLOAD` encode one choreography: the panel arrives, *then* the number lands on
102the word. `logo` and `chips` have nothing that lands later — the mark appearing IS the
103payload. `verify_motion.py` assumed the PAYLOAD model for every kind, so holding a logo to
104"entrance + 0.7s == the word" pushed its *entrance* 0.7s ahead of the word. The Lovable
105mark sat on screen most of a second before he said "Lovable", and every run reported `ok`.
106 
107> A verifier that models one primitive's choreography and applies it to all of them
108> certifies the bug. `ENTRANCE_IS_PAYLOAD` in `verify_motion.py` is the per-kind offset;
109> multi-lane boards skip the single-payload check and verify each lane's own cue instead.
110 
111New primitives must also be added to `brand_lint.py`'s render list. Three shipped outside
112it once, which meant the one-green rule was enforced on the four oldest renderers and
113silently not on the newest.
114 
115## The tier board (`tierboard.py`)
116 
117A different shape: **one persistent, cumulative board**, not eight panels flashing in and
118out. The hook shows the finished board with the lane contents **blurred** (tease the payoff,
119don't give it), then each channel drops onto its row as he says the grade. The viewer
120watches it fill in. That *is* the tier-list format.
121 
122It occupies the lower ~40%, so the captions **lift to 60% height** for exactly those frames
123rather than being hidden for 100 seconds.
124 
125## A panel and a caption cannot share a row
126 
127Captions sit at **0.78H (y≈499)**. A `stat` panel starts at **y=446**, a `steps` panel at
128**y=422**. So a caption lands *inside* the panel, and white type on a white surface is
129invisible at full opacity.
130 
131The tier board already lifted the captions to 0.60H. Nothing lifted them for the other
132primitives, and it shipped — because a caption over a *half-faded* panel is still legible,
133so it looks fine in exactly the frames you happen to spot-check.
134 
135> **Any frame carrying a `stat` or `steps` panel lifts the captions to 0.60H.**
136> `label` is exempt: its chip sits at 0.86H, ~40px clear of the caption band.
137 
138`build.py` resolves the plan **before** `caption_frames()` and unions those windows into
139`raise_frames`. Order matters: the captions have to know about the panels before they draw.
140 
141## `label()` accepts a `kicker` and does not draw it
142 
143The chip is one line by design; a kicker row would make it a panel. The argument stays in
144the signature so every primitive takes the same plan shape — but it is **silently dropped**,
145and that silence once cost a whole batch its step numbers. If you want the kicker seen, put
146it in `text`: `"01 · Build in public"`.
147 
148## Brand — do not invent values
149 
150From `gtm-production/brand/BRAND.md`:
151```
152surface #ffffff · ink #0a0a0a · muted #71717a · line #e4e4e7 · accent #10b981 (ONE dot, sparingly)
153font-sans Helvetica Neue (the same face as the captions) · radius 6/10/14/20
154"everything is ink, surface, and line" — no shadows, no gradients.
155```
156 
157---
158 
159## Retention: the 2026 five-second rule
160 
161> Any 5-second block with no cut, zoom, text or sound change is a **retention leak**.
162 
163The reference clip failed badly: **97 of 107 seconds (91%)** sat in a dead block, the worst a
16416.8-second stretch. All the cuts and graphics clustered at beat boundaries, and beats run
16511–17s.
166 
167**Fix: automatic pattern interrupts.** `plan_chunks()` splits long beats into ≤4.8s chunks
168with alternating framing — a snap change of camera mid-sentence. It is *not* a cut; the
169audio runs straight through. **18 interrupts, leak 91% → 0%.**
170 
171Two things this depends on:
172- **Fades only at real cut edges.** A 30ms fade at a framing split dips the voice audibly.
173 Verified: 0 discontinuities across all 18 internal joins.
174- **Use `ceil` for the chunk count.** Floor-plus-fudge left 5.7s chunks — still a leak.
175 
176A 3px `drawbox` progress bar is available (`--progress`) but is **off by default** — it
177reads as UI chrome bolted onto the frame. Cheap is not the same as good.
178 
179---
180 
181## THE RENDERING TRAP: PIL alpha does not composite
182 
183**`ImageDraw` with a partial-alpha fill OVERWRITES pixels.** A chip fading in therefore
184punches a transparent **hole** straight through the white panel beneath it, and the video
185shows through — the selected tier chip rendered as a **black box** instead of a green one.
186 
187> **Draw everything at full opacity on its own layer, then fade the layer.**
188> Never draw with a variable alpha. To animate a colour, LERP the colour — not the alpha.
189 
190## Text behind the subject
191 
192The "he's standing in front of the word" look. `behind.py`. Composite order per frame:
193 
194 video -> big text -> the person, cut back out on top -> captions + board
195 
196Two things make it work:
197 
1981. **It stays inside ONE encode.** The naive way is a second pass (render, re-read, segment,
199 re-encode) — and a second encode measurably softens the image. Instead the person is baked
200 into the same per-frame RGBA overlay the captions use, cut from the **raw source** frames
201 that ffmpeg is already decoding. The cut-out lands on top of itself: no seam.
2022. **Any chunk carrying a behind-text is forced to `wide`.** A zoomed chunk is scaled by
203 zoompan inside ffmpeg, and re-deriving that transform in PIL would never match
204 pixel-for-pixel — you would get a soft double-edge around him.
205 
206**Layer order is the whole trick.** Compositing the person LAST put his shoulder on top of
207the tier board and clipped the lanes. The person goes UNDER the UI: word, then him, then
208captions and board.
209 
210Segmentation: `rembg` (u2netp), ~0.2s/frame. Use it on **2-4 hero moments**, not the clip.
211 
212```json
213"behind": [{"cue": "for sure s tier", "text": "S TIER", "dur": 1.9}]
214```
215 
216## Verify
217 
218```bash
219python $S/verify_motion.py "<clip>"
220```
221Every graphic fires within 0.2s of its cue; no typo'd cue silently dropping a graphic; no
222overlapping panels; verdicts in ascending order.
223 

Discussion

Alternatives

Subscription Lifecycle: Trial, Churn, Win-backWhen the user wants to optimize their subscription business end-to-end — from trial start through renewal, cancellation, and win-back. Use when the user mentions "subscription lifecycle", "trial conversion", "churn", "cancellation", "win-back", "lapsed subscribers", "dunning", "billing retry", "grace period", "renewal rate", "subscriber LTV", or "resubscribe". For paywall design and pricing strategy, see monetization-strategy. For subscription analytics dashboards, see app-analytics.Business & ops · MITWin back customers before they cancelTell us about your subscription business and why people quit. Get back a ready-to-use plan to keep more customers and recover failed payments.Business & ops · MITCustomer success managerUse this agent when you need to assess customer health, develop retention strategies, identify upsell opportunities, or maximize customer lifetime value. Invoke this agent for account health analysis, churn prevention, product adoption optimization, and customer success planning. Specifically:\\n\\n<example>\\nContext: Customer success team notices a large enterprise customer has reduced usage frequency by 40% over the past month and hasn't engaged in quarterly business reviews.\\nuser: "We're concerned about TechCorp's engagement. Usage is down significantly. Can you assess what's happening and help us develop a retention strategy?"\\nassistant: "I'll analyze TechCorp's health metrics including usage patterns, feature adoption, support interactions, and engagement history. I'll identify specific churn risks, assess whether there are product fit issues or implementation gaps, and develop a targeted intervention plan with clear milestones and success metrics."\\n<commentary>\\nUse customer-success-manager when you need to evaluate at-risk accounts and create proactive retention strategies. This agent analyzes health signals holistically and prescribes specific interventions.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: Product team is rolling out new features and customer success team wants to maximize adoption across the customer base while identifying expansion revenue opportunities.\\nuser: "We're launching three new features next month. How do we ensure our customers actually adopt them? And which customers might be willing to upgrade to higher tiers?"\\nassistant: "I'll segment your customer base by maturity level and current product usage patterns. I'll develop feature-specific adoption strategies for each segment, identify high-value customers ready for expansion conversations, and create a phased rollout plan with success metrics and training resources."\\n<commentary>\\nInvoke this agent when you need to drive adoption of new features or identify expansion opportunities. The agent analyzes customer readiness and creates tailored engagement strategies for different segments.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: Quarterly renewal period is approaching and customer success team wants to prepare for renewal conversations with key accounts and identify which customers are at risk of non-renewal.\\nuser: "We have 40 accounts up for renewal in the next 90 days. Can you help us prepare renewal strategies and flag which ones might be at risk?"\\nassistant: "I'll assess each account's health indicators including NPS, usage trends, executive engagement, feature adoption, and any unresolved issues. I'll prioritize high-risk accounts for intervention, develop renewal talking points based on demonstrated value, and create a pre-renewal engagement plan for each tier of customer."\\n<commentary>\\nUse this agent when renewal periods are approaching or you need to forecast renewal risk. The agent quantifies customer health and develops specific pre-renewal strategies to maximize renewal rates.\\n</commentary>\\n</example>\\n\\nDoes not handle individual support tickets or FAQ/help-center content — hand those to customer-support. Does not own pre-sale technical POC/RFP work — hand that to sales-engineer. Does not finalize pricing or discount decisions — route to the account/deal team. Does not review or negotiate contract/legal terms — route to legal-advisor.Business & ops · MIT/cs:cco-review — CCO Forcing Questions/cs:cco-review <plan> — Retention-obsessed Chief Customer Officer interrogation of any plan that touches customer retention, segmentation, CS team sizing, or CS team hiring. Use when gross retention is slipping, before approving CSM headcount, or when deciding which customer segments to keep or fire. · MIT