**What this does:** paste the prompt below into Claude Code (the Code tab of the Claude desktop app, not the normal chat) and it sets up a small video editing system on your Mac, then cuts your first vertical video with animated subtitles, a title and a cover image, ready for you to post. Or it puts the finished video in Instagram Edits with a song, and nothing posts until you say yes. Or, if you like to finish it yourself, it builds a timeline for your own editor.
**You need:** a Mac and a paid Claude plan (Pro or Max; Claude Code is not in the free plan). Get the app at claude.com/download, click the Code tab, pick your Home folder, paste the prompt.
**Time:** about an hour the first time, most of it downloads (about 6 GB). Claude asks before it installs anything or writes files. Once, it asks you to paste one line into the Terminal app and type your Mac password (nothing shows while you type; that is normal).

---

Copy everything below this line into Claude Code.

---

This is an early version shared for people to try and improve. You are setting up a Content Engine on my Mac: a folder of plain files that lets you edit my short vertical videos (9:16) from my own footage, with subtitles and titles drawn on. I post the videos myself, unless I pick "Instagram Edits" and say yes to posting. Work through the steps below in order. Rules for the whole setup:

- Ask me before every install and download, saying in one plain sentence what it is and why.
- I am not technical. No jargon. One step at a time, then wait.
- If something fails, tell me in plain words what went wrong and what you suggest. Do not quietly switch approach.
- Never use em dashes in anything you write for me.
- The safety rules in `CLAUDE.md` below apply from the first command.

## Step 1: Check the Mac

Run `sw_vers`, `uname -m`, `sysctl -n hw.memsize` and `df -h ~`. Tell me in one short list: macOS version, chip (Apple Silicon or Intel), memory, free disk (I need about 6 GB). If macOS is older than 13 or the disk is too full, stop and tell me what to do.

## Step 2: Make the folder and the files

If `~/ContentEngine` already exists, stop and ask me before touching it. Otherwise ask my OK to create the folder and its files. Then create `~/ContentEngine` with the three folders I use:

- `raw`: I drop my footage in here.
- `work`: one working folder where you keep everything you make (my story, transcripts, storyboards, edits, images, animations, brand, rules, the speech model). Inside it: `ideas transcripts storyboards edits brand rules models`.
- `exports`: finished videos and captions, ready to post.

Plus `remotion` with `remotion/src` and `remotion/public/fonts` (the graphics engine for subtitles, titles and animations; I never need to open it) and `.claude/agents` (hidden, for the role files). Tell me in one line that these three are the only folders I need to look at, and how to find them in Finder: Go menu > Home > ContentEngine.

Then write each file below exactly as given, between its `=== FILE` line and `=== END` line. Use the Write tool, not shell heredocs, so nothing inside gets expanded.

=== FILE: ~/ContentEngine/env.sh
eval "$(/opt/homebrew/bin/brew shellenv 2>/dev/null || /usr/local/bin/brew shellenv)"
export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"
cd ~/ContentEngine
=== END

=== FILE: ~/ContentEngine/CLAUDE.md
# Content Engine

You help me make short vertical videos from my own footage. I post them myself, unless I pick "Instagram Edits" and say yes to posting (recipe I).

Before every edit, read: `work/rules/editing.md`, `work/rules/recipes.md`, `work/rules/style.md`, `work/brand/brand.md`, `work/rules/voice.md` (if it exists).

## Safety rules (always)

- Whenever something fails that you cannot fix in 2 tries, add it to `work/rules/style.md` under "Problems to report": the date, what I was doing, the exact error text. Tell me it is there so I can send it to whoever shared this setup.
- `raw/` is read-only. Never write, move, rename or delete anything in it. Every output goes to an explicit path inside `work/`, `exports/` or `remotion/public/` (fonts and cover frames for the graphics). The one exception is recipe I: a copy of a finished video from `exports/` into Instagram Edits' import folder.
- Never post anything without my clear yes for that video, in this chat, right before you press Share. A yes never carries over to the next video.
- Never put `-y` on a command whose output could land in `raw/`. Before running any ffmpeg command, check that its last argument (the output) is not a file in `raw/`.
- Start every Bash command that uses ffmpeg, ffprobe, whisper-cli, npm or npx with `source ~/ContentEngine/env.sh &&`.
- The Mac shell is zsh, which does not split words in variables. Run loops over several values inside `bash -c '...'`, or write one command per clip.
- Every ffmpeg and ffprobe gets `-nostats -v error`, except measurements that print their result (loudnorm, ebur128, silencedetect, ssim, fontselect): use `-nostats -v info` and grep the lines you need.
- Never delete my files.

## Every edit, in this order

1. **Story.** Ask me in one line what this video is about: the story I want to tell. Save it to `work/ideas/<date>-<short-name>.md`.
2. **Footage.** I record it and drop it into `raw/`. List the new files there. Ask: am I talking to camera, or is it shots with a voice recording over them? ffprobe each: length, size, rotation, colour (recipe C), frame rate and sound sample rate (recipe P). Tell me in one line the frame rate and sample rate this edit will use, and any clip that differs.
3. **Transcribe.** Recipe T. One cheap subagent (haiku) writes `work/transcripts/<clip>.md`: numbered sentences with times, each marked filler, restart, repeat or keep, plus the silences. Whisper adds and drops "um" between runs, so fillers are hints; confirm restarts and repeats by times and silences. From then on, read only the .md.
4. **Plan.** The editor role finds the footage for each line and writes `work/edits/<job>/plan.md`. Every cut is moved into real silence (recipe E) and checked for a visible jump (recipe J) here, before the storyboard.
5. **Storyboard.** One frame per piece into `work/storyboards/<job>/`, shown in `work/storyboards/<job>/storyboard.html` with each line and its times; open it, and list the pieces in chat. I go through it. Wait for my OK or changes. **Never render or build a timeline before I OK the storyboard.** If the plan changes after my OK for any reason, show me the changed storyboard again.
6. **Subtitles.** Build `work/edits/<job>/subs.srt` (recipe S). Show me the text as a numbered list to fix misheard words (Whisper cannot catch its own mistakes). Use my corrections exactly.
7. **Who finishes it.** Ask me which way, every time, before you cut: "Who finishes it? Claude cuts it for you, as a video file or in Instagram Edits. Or you finish it yourself from a timeline."
   - **"Video file."** Claude cuts it for me. Render the whole video once, from the approved plan and subtitles, with recipes C, A, G and L, into `work/edits/<job>/`. Remotion draws the subtitles and the title (recipe G); recipe S is only the fallback. The judge role (separate, read-only, cheap model) checks it. Fails get fixed and checked again; I never see a failed edit. After 2 failed rounds, tell me plainly what is stuck. Then I watch it and tell you what to change in plain words. Small changes go straight to a new render, bigger ones back to the storyboard.
   - **"Instagram Edits."** Claude cuts it for me, then Instagram Edits. Experimental, for a Mac with Apple silicon and Instagram Edits. Do "video file" and step 8 in full first, so the checked video and my picked caption are in `exports/`. Then recipe I: Instagram Edits adds a song, and you post it only on my yes.
   - **"Timeline."** I finish it myself. Recipe X. You find the footage, cut it, and put it in a timeline file for my editor (DaVinci Resolve, Final Cut Pro or Premiere Pro), with `subs.srt` next to it. Tell me how to open it. I finish the edit there and export it into `exports/` myself.
8. **Export.** No `work/rules/voice.md` yet? Run the voice role first. For "video file" and "Instagram Edits", copy the passed video to `exports/<date>-<short-name>.mp4` and make the poster (recipe G). Either way, the captions role writes `exports/<date>-<short-name>.txt`. Give me the full paths. It is ready to post. For "Instagram Edits", ask which caption I pick, then run recipe I.
9. **Learn.** Ask what I would change. Log each change in `work/rules/style.md`: `<date>: You changed X because Y` (plus any rule it overrode). When the same change shows up 3 times, offer to make it a rule in `work/rules/editing.md`.

## When rules clash

- Fewer cuts beats shorter pauses. If shrinking a pause would make a visible jump, keep the pause.
- My request beats the rules. Do what I ask, and log it in `work/rules/style.md` with the rule it overrides.

## Token budget

- Cheap model (haiku) for transcript summaries and the judge.
- One storyboard per edit. Change the plan on paper, render once.
- Never re-read transcript JSON once its .md summary exists.
- Run one ffmpeg job at a time (8 GB Macs run out of memory).
- Remotion renders are heavy: one render after the storyboard OK, never a trial render of the whole video, and `--concurrency=2` on 8 GB Macs.

## Not in this kit

Story structure, scheduled posts and auto-posting.

Every rule here is a starting point. Change the files and experiment.
=== END

=== FILE: ~/ContentEngine/work/rules/editing.md
# Editing rules

Starting rules. Change them, add your own, and watch what it does to your edits. How to measure them: `work/rules/recipes.md`.

## Cuts
1. Cut dead silences, but keep natural pauses: about 0.7 seconds between sentences. Only shrink a pause longer than about 1 second. Hold a beat longer after a strong line.
2. Cut only in silence, at the end of a sentence. Never mid-word, never mid-sentence.
3. Do not trust Whisper word times for cut points; they drift by up to half a second. Measure the sound at each cut edge against this clip's own quiet level (recipe E) and move the cut into real silence, with about 0.15 seconds of room sound kept.
4. Take out filler clusters ("um, you know, like"), false starts, restarts, and any sentence said twice (keep the better one). A single "I mean" or "like" is often just how I talk. Keep it unless it stacks up.
5. No choppy jump cuts on a face (recipe J). If a cut would visibly jump, move it to another sentence end where my head is still, cover it with another shot, or leave the stumble in. Fewer cuts beats a choppy edit.
6. Every cut needs a reason: dead air, filler, restart, repeat, or a detour that does not change the meaning. Never cut my words so they mean something I did not say.
7. End on a finished sentence, then stop.

## Sound
8. Voice in both ears. Voice memos and clip-on mics often record one side only. If one side is over 20 dB quieter, use the loud side for both ears; otherwise mix to mono into both.
9. No clicks and no sudden dead silence at joins. Put a 15 ms fade on each side of every join and keep the background room sound.
10. Sound and picture stay in sync: snap every cut to a whole frame, then cut both at exactly those times (recipe A), so they cannot drift apart over a long edit. Keep my footage's sample rate (recipe P).
11. Loudness about -14 LUFS (two-pass loudnorm), peaks under -1 dB. A quiet recording that needs more than about 10 dB of boost gets a light clean-up first, or the boost brings up wind and hiss.

## Picture
12. 1080x1920, at my footage's own frame rate (recipe P), never a fixed 24, 25 or 30. Crop to keep my face in frame; look at a frame to decide where.
13. Any clip that is HDR, 10-bit, or not bt709 gets converted to normal colour (recipe C), or it comes out washed-out or off.
14. Grade with the LUT named in `work/brand/brand.md` if there is one. Otherwise leave the colour alone.
15. Export size: keep the bitrate capped (recipe L) so a 60-second video stays well under 100 MB.

## Subtitles
16. Word for word what I say, swearing and slang included. Fix spelling only. I check the text before the render.
17. Drop any word Whisper "heard" in silence or background noise, and any repeated last sentence (both are common Whisper mistakes).
18. Style from `work/brand/brand.md`, drawn by Remotion (recipe G). Lines of about 30 characters, 2 lines at most, kept out of the bottom 15% of the screen where the app's buttons sit.
=== END

=== FILE: ~/ContentEngine/work/rules/recipes.md
# Recipes

Tested starting points. Run from `~/ContentEngine` after `source ~/ContentEngine/env.sh &&`. Adapt the parts in <angle brackets>.

## P: frame rate and sample rate, read from my footage
Never assume 24, 25, 30 or 48000; cameras and phones differ by country and setting. Every edit, for each clip:
```
ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate,avg_frame_rate -of csv=p=0 raw/<clip>
ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels -of csv=p=0 raw/<clip>
```
- Keep the frame rate as a fraction: `30000/1001` is 29.97, not 30. If `avg_frame_rate` is more than 1% off `r_frame_rate` (phones can record a variable rate), use the nearest of 23.976, 24, 25, 29.97, 30, 50, 59.94, 60.
- Mixed clips: the edit takes the frame rate most of the kept footage has (by length), and the sample rate most of the kept sound has. The other clips are conformed to it (recipe A; in a timeline, the editor does it).
- Write both into `plan.md` as `Frame rate:` and `Sample rate:`. The recipes below use them as `<fps>` and `<rate>`.

## T: transcribe
```
ffmpeg -nostats -v error -i raw/<clip> -vn -ac 1 -ar 16000 -c:a pcm_s16le work/transcripts/<clip>.wav
whisper-cli -m work/models/<model> -f work/transcripts/<clip>.wav -l auto -np -oj -of work/transcripts/<clip>
whisper-cli -m work/models/<model> -f work/transcripts/<clip>.wav -l auto -np -ml 1 -sow -oj -of work/transcripts/<clip>-words
```
First: sentences with punctuation. Second: rough word times. Then the level file (recipe E) and the silences:
```
ffmpeg -nostats -v info -i work/transcripts/<clip>.wav -af silencedetect=noise=<quiet>dB:d=0.3 -f null - 2>&1 | grep silence_
```

## E: cut edges in real silence
Level of every 10 ms of a clip:
```
ffmpeg -nostats -v error -i raw/<clip> -vn -af "aresample=48000,aformat=channel_layouts=mono,asetnsamples=n=480:p=0,astats=metadata=1:reset=1,ametadata=print:key=lavfi.astats.Overall.RMS_level:file=work/transcripts/<clip>.levels.txt" -f null -
```
Parse with python3 (standard library only): lines alternate `pts_time:<t>` and `RMS_level=<dB>` (`-inf` = silence). Background = 10th percentile of all levels; **quiet level = background + 8 dB**. Never a fixed number; noisy clips never reach one.
An out point moves to the nearest stretch of 50 ms or more below the quiet level after the last word, then on up to 0.15 s while quiet. An in point mirrors that, backwards from the first word.
Then snap each in and out to the nearest whole frame of its own clip (round(time × clip fps) ÷ clip fps). That can move a cut by half a frame, so check the 10 ms on each side of the snapped point is still below the quiet level; if not, move it one more frame into the silence (an in point earlier, an out point later). Cuts are never inside sound.

## J: will the cut jump on the face?
For each cut (out at A, in at B in the same clip):
```
ffmpeg -nostats -v error -ss <A-0.04> -i raw/<clip> -frames:v 1 -vf scale=270:-2 work/edits/<job>/j_a.png
ffmpeg -nostats -v error -ss <B> -i raw/<clip> -frames:v 1 -vf scale=270:-2 work/edits/<job>/j_b.png
ffmpeg -nostats -v info -i work/edits/<job>/j_a.png -i work/edits/<job>/j_b.png -lavfi ssim -f null - 2>&1 | grep -o "All:[0-9.]*"
```
Two frames in a row usually score about 0.85 to 0.95. A face cut under about 0.7 will jump: try nearby frames inside the same silence, cover it with another shot, or drop the cut.

## C: colour
```
ffprobe -v error -select_streams v:0 -show_entries stream=pix_fmt,color_primaries,color_transfer,color_space -of default=nw=1 raw/<clip>
```
- HDR (transfer `arib-std-b67` or `smpte2084`), start the video chain with:
  `zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=709,tonemap=tonemap=hable:desat=0,zscale=t=709:m=709:r=tv,format=yuv420p`
- 10-bit, or primaries or matrix not `bt709` (iPhone often shows `smpte432` and `bt2020nc`):
  `zscale=p=709:t=709:m=709:r=tv,format=yuv420p`
- Otherwise: `format=yuv420p`.
Do not use the `colorspace` filter. Compare one source frame with one render frame; if the colour looks off, tell me.

## A: assemble sound and picture
One `-filter_complex` per edit. Per piece i (input k, from A to B, both snapped to frames in recipe E, written to 6 decimals). Picture and sound are trimmed at exactly the same times, so they cannot drift over a long edit:
`[k:v]trim=start=A:end=B,setpts=PTS-STARTPTS,<colour (C) for this clip>,<crop for this clip>,scale=1080:1920,setsar=1[vi]; [k:a]atrim=start=A:end=B,asetpts=PTS-STARTPTS,aresample=<rate>,<both ears>,afade=t=in:d=0.015,afade=t=out:st=<B-A-0.015>:d=0.015[ai]`
then `[v1][a1][v2][a2]...concat=n=<pieces>:v=1:a=1[v][a]`.
Colour and crop go per piece because clips can differ (one HDR and one not, one 4K and one 1080p).
Video after concat: `fps=<fps>` (conforms any clip at another rate), LUT if any (`lut3d=work/brand/<file>.cube`). Write the picture alone to `work/edits/<job>/cut.mp4` (`-an -c:v libx264 -crf 16 -pix_fmt yuv420p`). No subtitles here: they go on top in recipe L.
Both ears: `pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1`; one-sided or mono source: `pan=stereo|c0=c0|c1=c0` using the loud side.
Write the joined sound to `work/edits/<job>/voice.wav` and measure it (L).

## L: loudness and export
Pass 1:
```
ffmpeg -nostats -v info -i work/edits/<job>/voice.wav -af loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json -f null - 2>&1 | tail -12
```
If `input_i` is below about -24 (more than 10 dB of boost needed), put `highpass=f=80,afftdn=nr=10:tn=1,` in front of loudnorm in both passes. Pass 2 uses the measured numbers:
`loudnorm=I=-14:TP=-1.5:LRA=11:measured_I=<>:measured_TP=<>:measured_LRA=<>:measured_thresh=<>:offset=<target_offset>:linear=true`
Final file, with the overlay from recipe G on the cut:
```
ffmpeg -nostats -v error -i work/edits/<job>/cut.mp4 -i work/edits/<job>/overlay.mov -i work/edits/<job>/voice.wav -filter_complex "[0:v][1:v]overlay=0:0:format=auto,format=yuv420p[v];[2:a]<clean-up if needed,>loudnorm=<pass 2>[a]" -map "[v]" -map "[a]" <export settings> work/edits/<job>/final.mp4
```
Without an overlay (the fallback), use `[0:v]<subtitles filter from S>[v]` instead. The overlay .mov is big; after the judge passes, ask me before removing it.
Export settings: `-c:v libx264 -preset medium -crf 21 -maxrate 10M -bufsize 20M -pix_fmt yuv420p -c:a aac -b:a 192k -ar <rate> -movflags +faststart`.
If wind or hiss in the pauses is clearly louder than in the source, tell me.

## S: subtitles
Build `work/edits/<job>/subs.srt` from the kept sentences, on the edit's timeline (earlier pieces' lengths plus time since this piece's in point). Start each cue at the speech onset in the level file, not the Whisper time.
Remotion draws these subtitles (recipe G). Only if Remotion fails: burn it as the last video filter (FontSize and MarginV are on a 288-high scale, so MarginV=50 sits just above the bottom 15%):
`subtitles=work/edits/<job>/subs.srt:force_style='FontName=<font>,FontSize=10,PrimaryColour=&H00FFFFFF,OutlineColour=&H80000000,BorderStyle=1,Outline=1,Shadow=1,Alignment=2,MarginV=50'`
If the burn fails, do not guess other ways around it. Export the video without burned subtitles, keep `subs.srt` next to it in `exports/`, tell me the exact error text, and tell me I can still add the .srt as captions in the app I post from. Then log the error in `work/rules/style.md` under "Problems to report" so I can send it to whoever shared this setup.
Add `:fontsdir=work/brand/fonts` when the font is a file in work/brand/. Font check: run once with `-v info` and grep `fontselect`. If the file it picked is not the font asked for, tell me.
Brand style stills are made with Remotion (recipe G, step 5).

## G: graphics with Remotion (subtitles, title, poster, animations)
Run from `~/ContentEngine/remotion` (after `source ~/ContentEngine/env.sh && cd remotion &&`). Renders are heavy on the computer: one render after the storyboard OK, and add `--concurrency=2` on an 8 GB Mac. The very first render downloads Remotion's own render browser into `remotion/node_modules/.remotion` (about 200 MB, approximate), so it pauses once.
1. Write `work/edits/<job>/overlay.json`: `{"fps": <fps as a number, 29.97 is 29.97003>, "duration": <cut length in seconds>, "brand": {<from brand.md, the fields in src/brand.ts>}, "title": {"text": "<title>", "dur": 1.5}, "lines": [{"s": <start>, "e": <end>, "words": [{"t": "<word>", "s": <start>, "e": <end>}]}]}`. Lines are the approved subtitle cues word for word, at the same times as `subs.srt`. Word times come from the words transcript, moved onto the edit's timeline (spread evenly across the cue if missing). A brand font file gets copied to `remotion/public/fonts/` and named in `fontFile`.
2. Render the overlay, transparent:
```
npx remotion render src/index.ts Overlay ../work/edits/<job>/overlay.mov --props=../work/edits/<job>/overlay.json --codec=prores --prores-profile=4444 --pixel-format=yuva444p10le --image-format=png --log=error
```
3. Recipe L lays it over the cut.
4. Poster: pick a frame with my face clear and eyes open, save it 1080x1920 through the same colour and crop chain to `remotion/public/<job>-cover.jpg`, write `work/edits/<job>/poster.json` (`{"image": "<job>-cover.jpg", "title": "<title>", "handle": "<handle>", "brand": {...}}`), then:
```
npx remotion still src/index.ts Poster ../exports/<date>-<short-name>-poster.jpg --props=../work/edits/<job>/poster.json --image-format=jpeg --log=error
```
5. Style stills for the brand interview: a frame of my clip to `remotion/public/style-frame.jpg`, an overlay.json with `"background": "style-frame.jpg"`, a 5-second line and a 1.5-second title, then `npx remotion still src/index.ts Overlay ../work/storyboards/style-<n>.png --frame=<a frame inside the title> --props=<that json>` and once more at a frame inside the line. Look at each still: if the font fell back to a default, say so.
6. If a Remotion render fails twice: use recipe S to burn `subs.srt` with ffmpeg instead, skip the title, and log the error under "Problems to report".
7. Animations: `RouteMap` is a starter (places with lat and lon, a line drawn between dots). Render it like the overlay with `--props` (`fps`, `seconds`, `places`, `brand`, `background`) to an .mp4 and use it as a shot. New animations go in `remotion/src` the same way, registered in `Root.tsx`.

## X: timeline for my editor
Use the editor named in `work/brand/brand.md`; if none, ask me and save it there.
- DaVinci Resolve or Final Cut Pro: write `work/edits/<job>/timeline.fcpxml` (FCPXML 1.9). Premiere Pro: write `work/edits/<job>/timeline.xml` (Final Cut Pro 7 XML, which Premiere imports).
- Write it with python3, standard library only, from the approved `plan.md`. One asset per clip, pointing at the original file in `raw/` by its full `file://` path (spaces encoded). Never copy or change the clips.
- A 1080x1920 sequence at `<fps>`, with `audioRate` at `<rate>` (`48k`, `44.1k`), from recipe P. Each clip gets its own `format` with its own frame rate and size, and its asset its own `audioRate` and `audioChannels`. Every in point is a whole frame of its clip and every length a whole frame of the sequence (snapped and re-checked for silence in recipe E), written as frame-based times (for example `1001/30000s` steps at 29.97 fps).
- Cameras and phones stamp a start timecode on each clip (ffprobe `-show_entries format_tags=timecode:stream_tags=timecode`; none means 00:00:00:00). The asset's `start` is that timecode as a time, and each piece's `start` is that plus its in point. Without it, Resolve says the clip does not match and cancels the import. A timecode with `;` is drop-frame: count its frames the drop-frame way.
- Each kept piece goes on the main track in plan order, with its sound, from its in point for its length, with `<adjust-conform type="fill"/>` inside it so a wide clip fills the tall frame from the centre. No LUT or subtitles baked in: list the crop from the plan in chat so I can adjust it.
- No audio fades in the file: Resolve ignores them. Clean joins come from every cut sitting in silence.
- Check it: the file parses with `xml.etree`, the sequence frame rate and audio rate match `plan.md`, every piece's in and out are within one frame of the plan, and the total length is the sum of the snapped pieces.
- How to open it in Resolve: tell me the frame rate you read (for example 29.97, not 30). I make a new project for every timeline, never one that already has a timeline in it. Before importing: File > Project Settings > Master Settings, set Timeline frame rate and Playback frame rate both to that number, Save. Then File > Import > Timeline. If the two do not match, the sound crackles and peaks on playback, and Resolve locks them once a timeline is in. With Resolve Studio and external scripting on, you can do this for me instead: create the new empty project through the API, change none of its settings, import with `ImportTimelineFromFile` (an empty project takes the file's frame rate, playback rate and size), then read `timelineFrameRate` and `timelinePlaybackFrameRate` back and tell me they match. Do not set `timelineFrameRate` first: the API refuses `timelinePlaybackFrameRate`, so playback would stay at 24.
- Final Cut Pro: File > Import > XML. Premiere Pro: File > Import. Then import `subs.srt` as captions (Resolve: drag it onto the timeline; Final Cut Pro: File > Import > Captions; Premiere Pro: File > Import, then drag it onto the timeline).

## I: finish and post in Instagram Edits (experimental)
Only for "Instagram Edits", after step 8. The video that goes in is the checked one from `exports/`, never a file from `raw/`. Instagram Edits adds the song and does the posting. Never cut, trim, caption or re-edit the video in Instagram Edits, and leave my voice's level alone (no Instagram Edits loudness or ducking on it). The subtitles are already on, so no Instagram Edits captions.
- Needs: a Mac with Apple silicon, Instagram Edits for Mac with Settings > Automation > Enable Automation Server on, and its tools (`mcp__metapilot-native__*`) in this session. They load in the Code tab of the Claude desktop app. Claude Code in Terminal older than 2.1.309 fails with "tools fetch failed ... ttlMs ... cacheScope": tell me to use the desktop app, or ask my OK to run `claude update`. The tools change with app updates, so list them first and use only what is there.
1. Ask me in one message: a song or a mood for the music; trial reel or normal post; any location or people to tag. The caption is the one I picked in step 8, word for word.
2. `appState` to check Instagram Edits is open. `newProject`, then `setFrameRate` before adding any media: the video's frame rate if it is 24, 30 or 60, otherwise 30 (Instagram Edits allows only those three).
3. Copy the video from `exports/` into `~/Library/Containers/com.burbn.basel.mac/Data/tmp/` (Instagram Edits only imports from there), then `addMedia` from there. On "disk I/O error", tell me to quit and reopen Instagram Edits, then try once more. If the copy itself fails ("Operation not permitted"), go to the fallback below.
4. `listMusicTracks` with my song or mood (a licensed catalogue). Show me 3 options and add the one I pick with `addSongToComposition` (its `audioAssetId`). `setVolume` on the song only, about 0.12 (about -19 dB), so it sits under my voice. `saveProject`. Tell me I can swap the song in the app.
5. Posting has no tool. It happens on screen with computer use, asking for access to Instagram Edits only. If computer use is not in this session, stop here and tell me: in Instagram Edits press Export, then Share to Instagram, and post it myself.
6. On screen: press Export. If it asks "Save without music?" (a licensed song over 60 seconds), press Save Anyway: only the copy saved to the Mac loses the song, the post keeps it. Then Share to Instagram. Type my caption, and set the Trial reel switch, location and tags the way I said.
7. Stop before Share. Take a screenshot and ask me "Post it?", naming the Instagram account shown, the song, trial or normal, and the caption's first line. It posts straight away (there is no scheduling) and it is public. Press Share only on my clear yes for this video.
8. Tell me the upload can take about 20 minutes (Instagram Edits makes a big file, about 250 MB for a minute), so keep the Mac awake and Instagram Edits open. Check the screen every few minutes until it shows "Shared successfully". Never press Share a second time. If you cannot tell whether it posted, ask me to look in Instagram instead of trying again.
9. The Instagram Edits export also lands in `~/Movies/Edits/Exports/`. If I want it checked, copy it into `work/edits/<job>/` and run the judge's loudness and both-ears checks. Instagram Edits makes 1440x2560 HEVC at the project's frame rate, so that is expected, not a FAIL.
- Fallback: if the tools are missing, will not connect, a screen does not match these steps, or a step fails twice, stop. Say "Instagram Edits did not connect, here is your finished video, drag it into Instagram Edits yourself" with the path, and do not debug the app or click around. Log it under "Problems to report".
=== END

=== FILE: ~/ContentEngine/work/rules/style.md
# My style (learning log)

One line per change I ask for: `<date>: You changed X because Y`. Claude reads this before every edit and applies it; a change seen 3 times gets offered as a rule in `work/rules/editing.md`.

## Log
=== END

=== FILE: ~/ContentEngine/work/brand/brand.md
# Brand

Claude fills this in from any files in `work/brand/` (fonts, logo, .cube LUT, guide). If there are none, it asks me 5 questions: my handle; 2 or 3 colours I use; the font feel I like (clean, bold, typewriter, handwritten); subtitles low, middle or high on screen; my colour look (a LUT file, or natural).
Then it makes 2 or 3 subtitle styles and 2 or 3 title styles as Remotion stills on a frame of my clip (recipe G, step 5; Mac fonts such as Helvetica Neue, Avenir Next, Futura, or a Google font file I OK), checks each font loaded, and I pick one of each.

- Handle:
- Colours:
- Subtitle font:
- Subtitle size, colour, outline or shadow:
- Subtitle position:
- Subtitle extras (spoken word in the accent colour, dark box behind, or neither):
- Title style (bold in the accent colour, or a box), title font:
- LUT file (or none):
- Logo (or none):
- Editor for timelines (DaVinci Resolve, Final Cut Pro, Premiere Pro, or none):
- Finish in Instagram Edits to add a song and post (yes or no):
=== END

=== FILE: ~/ContentEngine/.claude/agents/editor.md
---
name: editor
description: Plans the cut for one edit from the transcript summary. Use at the plan step and for any change to the cut.
tools: Read, Write, Bash, Glob, Grep
---
You are the editor. Follow the safety rules in `CLAUDE.md` (including `source ~/ContentEngine/env.sh &&`). Read `work/rules/editing.md`, `work/rules/recipes.md`, `work/rules/style.md`, `work/brand/brand.md`, then the transcript .md (never the JSON).
For every possible cut, ask in this order: does the feeling survive? Does the meaning survive? Is this a filler, or just how they talk? Is the cut at a sentence end, in silence (recipe E)? Will a jump show on the face (recipe J)?
Write `work/edits/<job>/plan.md`: each piece kept (clip, in, out, the line), why the cut before it was made, the edge levels and jump score of each cut, the crop, and the total length. When in doubt, cut less.
=== END

=== FILE: ~/ContentEngine/.claude/agents/judge.md
---
name: judge
description: Checks a rendered edit against work/rules/editing.md before the user sees it. Read-only. Use after every render.
tools: Read, Bash, Glob, Grep
model: haiku
---
You are the judge. You did not make this edit, and you never change files. Use Bash only for ffprobe and ffmpeg measurements, writing nothing outside `work/edits/<job>/`. Start every such command with `source ~/ContentEngine/env.sh &&`.
Read `work/rules/editing.md`, `work/rules/recipes.md` and `work/edits/<job>/plan.md`. Then measure:
- Cut edges: in the source, the 50 ms past every out point and before every in point is below the clip's quiet level (recipe E).
- Both ears: left and right (astats) within 3 dB. Sync: audio and video lengths both within one frame of the plan's total (the sum of the snapped pieces).
- Joins: no sudden level jump in 10 ms, no drop below the room sound.
- Pauses: none under 0.5 seconds; list any over 1 second.
- Loudness: integrated -14 LUFS, plus or minus 1 (ebur128). Peaks under -1 dB.
- Picture: 1080x1920 at the plan's frame rate, sound at the plan's sample rate (ffprobe); one frame next to the source frame does not look washed out.
- Subtitles: `subs.srt` text matches what the user approved; no cue over 2 lines; a frame with a subtitle shows it readable, above the bottom 15%.
- Last line is a finished sentence. File size under about 100 MB per minute.
- Graphics: a frame inside the first second shows the title; the poster exists in `exports/`. If the subtitles were burned by ffmpeg instead, say so.
Reply PASS or FAIL per check, one line each, with the measured number and the time. Any FAIL goes back to the editor, never to the user.
=== END

=== FILE: ~/ContentEngine/.claude/agents/captions.md
---
name: captions
description: Writes the post caption for a finished edit. Use at the export step.
tools: Read, Write
---
Read `work/rules/voice.md` and the transcript .md of the edit. Write 2 caption options in my voice, each one short paragraph. Add what the video leaves out; do not retell it. End with a question people can answer in the comments. Never claim anything I did not say. Up to 3 hashtags, only if they fit. Save both options to the export .txt, and I pick.
=== END

=== FILE: ~/ContentEngine/.claude/agents/voice.md
---
name: voice
description: Interviews the user once so captions sound like them. Writes work/rules/voice.md. Use before the first caption, or when the user says the captions sound wrong.
tools: Read, Write
---
Ask one question at a time, 5 in total: paste 2 or 3 old captions or messages you wrote; words you always use; words you would never use; swearing, yes or no; emojis and hashtags, yes or no.
Write `work/rules/voice.md`: how they write (sentence length, tone, punctuation habits), their words, their banned words, and 2 short example lines in their voice. Keep their exact words; do not polish them.
=== END

=== FILE: ~/ContentEngine/remotion/package.json
{
  "name": "content-engine-graphics",
  "private": true,
  "dependencies": {
    "remotion": "4.0.529",
    "@remotion/cli": "4.0.529",
    "react": "18.3.1",
    "react-dom": "18.3.1"
  }
}
=== END

=== FILE: ~/ContentEngine/remotion/src/index.ts
import {registerRoot} from 'remotion';
import {Root} from './Root';
registerRoot(Root);
=== END

=== FILE: ~/ContentEngine/remotion/src/brand.ts
import {useEffect, useState} from 'react';
import {continueRender, delayRender, staticFile} from 'remotion';

// Filled in from work/brand/brand.md for every render.
export type Brand = {
  font: string; // font family, a Mac font name or the name given to fontFile
  fontFile?: string; // optional font file in public/fonts/
  weight: number; // 400 regular, 700 bold, 900 black
  size: number; // subtitle size in px on the 1080x1920 frame
  text: string; // subtitle colour
  accent: string; // title colour and the spoken word, when highlight is on
  highlight: boolean; // colour the word being spoken
  box: boolean; // dark box behind the subtitles
  position: 'low' | 'middle' | 'high';
  titleFont?: string;
  titleFile?: string;
  titleStyle: 'bold' | 'box';
};

export const demoBrand: Brand = {
  font: 'Helvetica Neue', weight: 700, size: 64, text: '#FFFFFF', accent: '#FFD84D',
  highlight: false, box: false, position: 'low', titleStyle: 'bold',
};

// Bottom edge of the subtitles, in px from the bottom. 'low' stays above the app's buttons (bottom 15%).
export const BOTTOM = {low: 380, middle: 860, high: 1340};
export const SHADOW = '0 2px 6px rgba(0,0,0,0.85), 0 0 18px rgba(0,0,0,0.55)';

// Waits for the brand fonts before any frame is taken, so no frame renders in a fallback font.
export const useBrandFonts = (b: Brand) => {
  const [handle] = useState(() => delayRender('brand fonts'));
  useEffect(() => {
    const files: [string, string][] = [];
    if (b.fontFile) files.push([b.font, b.fontFile]);
    if (b.titleFile) files.push([b.titleFont ?? b.font, b.titleFile]);
    Promise.all(files.map(([family, file]) =>
      new FontFace(family, `url(${staticFile('fonts/' + file)})`).load().then((f) => {
        (document.fonts as unknown as {add: (x: FontFace) => void}).add(f);
      })))
      .then(() => document.fonts.ready)
      .catch((e) => console.error('Font did not load', e))
      .finally(() => continueRender(handle));
  }, [b.font, b.fontFile, b.titleFont, b.titleFile, handle]);
};
=== END

=== FILE: ~/ContentEngine/remotion/src/Captions.tsx
import React from 'react';
import {AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion';
import {BOTTOM, Brand, SHADOW} from './brand';

export type Word = {t: string; s: number; e: number};
export type Line = {s: number; e: number; words: Word[]};

// Word-for-word subtitles: one line at a time, each line pops in gently.
export const Captions: React.FC<{lines: Line[]; brand: Brand}> = ({lines, brand}) => {
  const frame = useCurrentFrame();
  const {fps} = useVideoConfig();
  const t = frame / fps;
  const line = lines.find((l) => t >= l.s && t < l.e);
  if (!line) return null;
  const p = spring({frame: (t - line.s) * fps, fps, config: {damping: 18, stiffness: 160, mass: 0.6}});
  return (
    <AbsoluteFill>
      <div style={{position: 'absolute', left: 90, right: 90, bottom: BOTTOM[brand.position], display: 'flex', justifyContent: 'center'}}>
        <div
          style={{
            fontFamily: `"${brand.font}"`, fontWeight: brand.weight, fontSize: brand.size, lineHeight: 1.2, textAlign: 'center',
            color: brand.text, textShadow: brand.box ? 'none' : SHADOW,
            background: brand.box ? 'rgba(0,0,0,0.55)' : 'transparent', padding: brand.box ? '10px 22px' : 0, borderRadius: 14,
            opacity: p, transform: `translateY(${interpolate(p, [0, 1], [14, 0])}px) scale(${interpolate(p, [0, 1], [0.96, 1])})`,
          }}
        >
          {line.words.map((w, i) => (
            <span key={i} style={{color: brand.highlight && t >= w.s && t < w.e ? brand.accent : undefined}}>
              {w.t}{i < line.words.length - 1 ? ' ' : ''}
            </span>
          ))}
        </div>
      </div>
    </AbsoluteFill>
  );
};
=== END

=== FILE: ~/ContentEngine/remotion/src/TitleCard.tsx
import React from 'react';
import {AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion';
import {Brand, SHADOW} from './brand';

export type Title = {text: string; sub?: string; dur: number};

export const titleStyle = (brand: Brand): React.CSSProperties => {
  const box = brand.titleStyle === 'box';
  return {
    fontFamily: `"${brand.titleFont ?? brand.font}"`, fontWeight: 900, fontSize: 132, lineHeight: 1.0, letterSpacing: '-0.02em',
    textAlign: 'center', maxWidth: 940, whiteSpace: 'pre-line',
    color: box ? '#111111' : brand.accent, background: box ? brand.accent : 'transparent',
    padding: box ? '18px 34px' : 0, textShadow: box ? 'none' : SHADOW,
  };
};

// Big bold title for the first second or two, in the upper third.
export const TitleCard: React.FC<{title: Title; brand: Brand}> = ({title, brand}) => {
  const frame = useCurrentFrame();
  const {fps} = useVideoConfig();
  const end = Math.max(1, Math.round(title.dur * fps));
  const pop = spring({frame, fps, config: {damping: 14, stiffness: 120}});
  const out = interpolate(frame, [end - 8, end], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
  return (
    <AbsoluteFill style={{alignItems: 'center', paddingTop: 380, opacity: out}}>
      <div style={{...titleStyle(brand), opacity: pop, transform: `scale(${interpolate(pop, [0, 1], [0.85, 1])})`}}>
        {title.text}
        {title.sub ? <div style={{fontSize: 46, fontWeight: 600, marginTop: 18, letterSpacing: 0}}>{title.sub}</div> : null}
      </div>
    </AbsoluteFill>
  );
};
=== END

=== FILE: ~/ContentEngine/remotion/src/Overlay.tsx
import React from 'react';
import {AbsoluteFill, Img, Sequence, staticFile, useVideoConfig} from 'remotion';
import {Brand, demoBrand, useBrandFonts} from './brand';
import {Captions, Line} from './Captions';
import {Title, TitleCard} from './TitleCard';

// Everything drawn on top of the cut: subtitles plus the opening title, on a transparent background.
// 'background' (a file in public/) is only for style stills, so the user sees it on their own frame.
export type OverlayProps = {fps: number; duration: number; brand: Brand; title: Title; lines: Line[]; background?: string};

export const Overlay: React.FC<OverlayProps> = ({brand, title, lines, background}) => {
  useBrandFonts(brand);
  const {fps} = useVideoConfig();
  return (
    <AbsoluteFill>
      {background ? <Img src={staticFile(background)} style={{width: '100%', height: '100%', objectFit: 'cover'}} /> : null}
      <Captions lines={lines} brand={brand} />
      {title.text ? (
        <Sequence durationInFrames={Math.max(1, Math.round(title.dur * fps))}>
          <TitleCard title={title} brand={brand} />
        </Sequence>
      ) : null}
    </AbsoluteFill>
  );
};

export const overlayDemo: OverlayProps = {
  fps: 30, duration: 5, brand: demoBrand, title: {text: 'Your title', dur: 1.5},
  lines: [{s: 1.6, e: 4.5, words: [{t: 'Subtitles', s: 1.6, e: 2.2}, {t: 'go', s: 2.2, e: 2.5}, {t: 'here', s: 2.5, e: 3.1}]}],
};
=== END

=== FILE: ~/ContentEngine/remotion/src/Poster.tsx
import React from 'react';
import {AbsoluteFill, Img, staticFile} from 'remotion';
import {Brand, demoBrand, SHADOW, useBrandFonts} from './brand';
import {titleStyle} from './TitleCard';

// Cover image: the chosen frame plus the title. The profile grid crops to the middle 3:4,
// so the title sits between 300 and 1600 px from the top.
export type PosterProps = {image: string; title: string; handle?: string; brand: Brand};

export const Poster: React.FC<PosterProps> = ({image, title, handle, brand}) => {
  useBrandFonts(brand);
  return (
    <AbsoluteFill style={{backgroundColor: '#000000'}}>
      <Img src={staticFile(image)} style={{width: '100%', height: '100%', objectFit: 'cover'}} />
      <AbsoluteFill style={{background: 'linear-gradient(180deg, rgba(0,0,0,0.45) 0%, rgba(0,0,0,0) 40%)'}} />
      <AbsoluteFill style={{alignItems: 'center', paddingTop: 320}}>
        <div style={titleStyle(brand)}>{title}</div>
      </AbsoluteFill>
      {handle ? (
        <div style={{position: 'absolute', bottom: 380, width: '100%', textAlign: 'center', fontFamily: `"${brand.font}"`,
          fontWeight: 600, fontSize: 40, color: brand.text, textShadow: SHADOW}}>{handle}</div>
      ) : null}
    </AbsoluteFill>
  );
};

export const posterDemo: PosterProps = {image: 'cover.jpg', title: 'Your title', brand: demoBrand};
=== END

=== FILE: ~/ContentEngine/remotion/src/RouteMap.tsx
import React from 'react';
import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion';
import {Brand, demoBrand, useBrandFonts} from './brand';

// A starter animation: a line draws from place to place and each dot and name appears as the line reaches it.
// Simple flat map, north up, fitted to the frame. Change it, or ask Claude for a new animation in the same way.
export type Place = {name: string; lat: number; lon: number};
export type RouteProps = {fps: number; seconds: number; places: Place[]; brand: Brand; background: string};

export const RouteMap: React.FC<RouteProps> = ({places, brand, background}) => {
  useBrandFonts(brand);
  const frame = useCurrentFrame();
  const {fps, durationInFrames, width, height} = useVideoConfig();
  const pad = 160;
  const xs = places.map((p) => p.lon);
  const ys = places.map((p) => -p.lat);
  const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
  const k = Math.min((width - 2 * pad) / Math.max(x1 - x0, 1e-6), (height - 2 * pad - 400) / Math.max(y1 - y0, 1e-6));
  const pts = places.map((p) => ({name: p.name, x: width / 2 + (p.lon - (x0 + x1) / 2) * k, y: height / 2 + (-p.lat - (y0 + y1) / 2) * k}));
  const cum = [0];
  for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y));
  const total = Math.max(cum[cum.length - 1], 1);
  const drawn = interpolate(frame, [fps * 0.5, durationInFrames - fps], [0, total],
    {extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.cubic)});
  const d = pts.map((p, i) => `${i ? 'L' : 'M'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
  return (
    <AbsoluteFill style={{backgroundColor: background}}>
      <svg width={width} height={height}>
        <path d={d} fill="none" stroke={brand.accent} strokeWidth={8} strokeLinecap="round" strokeLinejoin="round"
          strokeDasharray={total} strokeDashoffset={total - drawn} />
        {pts.map((p, i) => {
          const on = interpolate(drawn - cum[i], [0, total * 0.03], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
          return (
            <g key={i} opacity={on}>
              <circle cx={p.x} cy={p.y} r={4 + 12 * on} fill={brand.text} />
              <text x={p.x} y={p.y - 32} textAnchor="middle" fontFamily={`"${brand.font}"`} fontWeight={700} fontSize={44} fill={brand.text}>
                {p.name}
              </text>
            </g>
          );
        })}
      </svg>
    </AbsoluteFill>
  );
};

export const routeDemo: RouteProps = {
  fps: 30, seconds: 6, background: '#101418', brand: demoBrand,
  places: [{name: 'Lisbon', lat: 38.72, lon: -9.14}, {name: 'Madrid', lat: 40.42, lon: -3.7}, {name: 'Barcelona', lat: 41.39, lon: 2.17}],
};
=== END

=== FILE: ~/ContentEngine/remotion/src/Root.tsx
import React from 'react';
import {Composition, Still} from 'remotion';
import {Overlay, overlayDemo} from './Overlay';
import {Poster, posterDemo} from './Poster';
import {RouteMap, routeDemo} from './RouteMap';

// fps and length come from each render's props, so they always match the edit (recipe P).
export const Root: React.FC = () => (
  <>
    <Composition id="Overlay" component={Overlay} width={1080} height={1920} fps={30} durationInFrames={150} defaultProps={overlayDemo}
      calculateMetadata={({props}) => ({fps: props.fps, durationInFrames: Math.max(1, Math.round(props.duration * props.fps))})} />
    <Still id="Poster" component={Poster} width={1080} height={1920} defaultProps={posterDemo} />
    <Composition id="RouteMap" component={RouteMap} width={1080} height={1920} fps={30} durationInFrames={180} defaultProps={routeDemo}
      calculateMetadata={({props}) => ({fps: props.fps, durationInFrames: Math.max(1, Math.round(props.seconds * props.fps))})} />
  </>
);
=== END

## Step 3: Install the tools

Tell me you need three free tools: ffmpeg (cuts and checks video and sound), whisper-cpp (speech to text) and Node (runs Remotion, which draws the subtitles, titles and animations), installed through Homebrew, the standard Mac installer for free tools. Ask for my OK.

- Check for Homebrew: `command -v brew || ls /opt/homebrew/bin/brew /usr/local/bin/brew`.
- If it is missing, you cannot install it yourself, because it needs my Mac password. Tell me to open the Terminal app (Cmd+Space, type Terminal) and paste this line:
  `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
  Tell me to type my Mac password when asked (nothing shows while typing, that is normal) and press Return when asked. It may also install Apple's Command Line Tools: 10 to 30 minutes in all. If it says my account is not an administrator, stop and explain. Wait until I say done. With my OK, add the Homebrew `shellenv` line from `env.sh` to `~/.zprofile`.
- If Homebrew was already there, run `brew list --versions` and `brew install --dry-run ffmpeg-full whisper-cpp node`. If I already have tools installed, tell me that installing can upgrade shared parts my other tools use (show me what the dry run would upgrade), and ask before going on.
- Run `brew install ffmpeg-full whisper-cpp node`. It has to be `ffmpeg-full`: plain `ffmpeg` has no subtitle or HDR filters. `env.sh` puts it first on the path.
- Check the filters and print any that are missing:
  `source ~/ContentEngine/env.sh && for f in subtitles lut3d loudnorm ebur128 silencedetect astats zscale tonemap ssim afftdn; do ffmpeg -hide_banner -filters 2>/dev/null | grep -qw "$f" || echo "MISSING: $f"; done`
  If anything prints, tell me which ones and stop.
- Set up Remotion: `source ~/ContentEngine/env.sh && cd remotion && npm install --no-fund --no-audit` (about 500 MB, approximate). Tell me once, in these words: Remotion is free for individuals, non-profits and for-profit organisations with up to 3 employees; bigger companies need a paid Company License (remotion.pro/license). Its render browser downloads on the first render (recipe G).
- Check whisper: `source ~/ContentEngine/env.sh && whisper-cli --help 2>&1 | grep -E " -(l|ml|sow|oj|of|np) "`.

## Step 4: Download one speech model

Ask for my OK, then download into `~/ContentEngine/work/models/`:
- Apple Silicon: `curl -L --fail -C - -o ~/ContentEngine/work/models/ggml-large-v3-turbo-q5_0.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin` (about 550 MB)
- Intel: `curl -L --fail -C - -o ~/ContentEngine/work/models/ggml-small.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin` (about 470 MB; slower but fine)

If it stops, run the same line again; it resumes. Check `ls -l` shows over 400 MB. Add a line to `CLAUDE.md` naming the model file.

## Step 5: Brand

Ask me: do you have a brand kit (fonts, logo, colour LUT)? If yes, drop it into `~/ContentEngine/work/brand/`. If no, 5 quick questions now, or a clean default (white subtitles, soft shadow, Helvetica Neue, lower third) and the questions after the first video. Follow `work/brand/brand.md`.

## Step 6: My first clip

Tell me to record one clip and drop my footage in `~/ContentEngine/raw/` (`open ~/ContentEngine/raw` opens it in Finder): me talking to camera, or shots plus a voice recording, under 3 minutes for a first try. From an iPhone, AirDrop it, or in Photos use File > Export > Export Unmodified Original (dragging can leave an iCloud placeholder that is not really on the Mac). Then drag the AirDropped or exported files (they land in Downloads) into the raw folder in Finder. Check ffprobe shows a real length before going on.

## Step 7: First edit

Run the workflow in `~/ContentEngine/CLAUDE.md` from step 1, safety rules included. The role files are not loaded in this session, so run each role with the Agent tool (general-purpose), its role file pasted in as instructions; the judge on haiku. Wait for my OK at the storyboard and at the subtitle text, then ask me who finishes it (step 7): a video file, Instagram Edits, or a timeline I finish myself. Instagram Edits gets set up at the end of this setup, so if I pick it now, make the video file and tell me Instagram Edits works from my next session.

## Step 8: Finish

Tell me in a short list:
- my three folders: `raw` (drop your footage in), `work` (everything Claude makes), `exports` (ready to post);
- where my video or timeline and my caption are;
- next time, open the Claude app, click Code, pick the ContentEngine folder in my Home folder, then say "new edit" (or in Terminal: `cd ~/ContentEngine && claude`);
- the rules live in `~/ContentEngine/work/rules/`. To change one, I just tell you in the chat. The system learns from every change I ask for;
- if something breaks, I tell you what happened in this chat first.

Then ask me once: do I use Instagram Edits? If no, or this Mac is Intel, skip everything about Instagram Edits. If yes, tell me this optional, experimental part, then wait: get Instagram Edits for Mac (free, Apple silicon) at edits.com/desktop and sign in with Instagram; in Instagram Edits, open Settings > Automation and turn on "Enable Automation Server"; copy the `claude mcp add` line it shows and paste it into the Terminal app myself. That line holds my private key: never ask for it or show it. If Terminal says "command not found: claude", ask my OK to install Claude Code for Terminal with its official installer, then I paste the line again. It works from my next session in the Code tab of the Claude desktop app (Claude Code in Terminal needs 2.1.309 or newer). For you to post for me, computer use has to be on in the Claude desktop app; without it, you add the song and I post it myself. Later, "connection refused" means the server is off in Instagram Edits; "403" means the key changed: run `claude mcp remove metapilot-native` and paste the new line.
