| name | pocketjs-video-outro |
| description | Append a PocketJS-branded, animated end card to a local video (screen recording, demo capture, phone clip). Renders the dark brand card — logo glyph, "PocketJS" wordmark, "Bare Metal Modern Web" tagline, pocketjs.dev — with headless Chrome, then composites it with a crossfade and a staggered text entrance while preserving the source's original audio. Use when asked to add an outro / end card / 片尾 to a video, brand a recording, or produce a shareable clip with the PocketJS sign-off. |
PocketJS Video Outro
Overview
Turns any local video into a shareable clip that ends on the PocketJS brand card.
The card is built from the same visual system as site/assets/og-image.svg — dark
#171226 field, faint blueprint grid, yellow/pink corner glows, the lens/viewfinder
logo glyph, the wordmark, and the hero tagline. It is rendered by headless Chrome
(full gradient/shadow/font fidelity; the site font stack falls back to system SF),
then ffmpeg crossfades the source into the card and animates the text in.
Design choices baked into the pipeline:
- Crossfade first, text second. The source dissolves into the empty branded
background; the type only starts animating once the transition has settled, so it
never fights the crossfade.
- Staggered entrance. Logo → tagline → URL, each fades in and eases up
(~20-30px, ease-out cubic), ~0.35s apart, then holds.
- Audio is the source's, never synthesized. The card is silent; the original
track is preserved and gently faded out under the transition (no voiceover).
- Shareable SDR output. HLG/PQ phone footage is perceptually tone-mapped to
BT.709 before compositing, and the browser-rendered sRGB card is converted into
the same color space. This avoids mixing an SDR card directly into HDR code values.
Requirements
ffmpeg / ffprobe on PATH. HDR inputs require FFmpeg 8+ for swscale's
transfer/primaries conversion and perceptual tone mapping; SDR inputs do not.
- A Chromium-family browser (Google Chrome, Chromium, Edge, or Brave) — used only
to screenshot the card layers. The script auto-detects it.
Standard workflow
One Bun command produces the finished file (the driver is Bun TypeScript — this
repo keeps command wrappers in Bun, not shell scripts):
bun skills/pocketjs-video-outro/scripts/make-outro.ts -i ~/Downloads/clip.mov
For an X upload, enable the compatibility mode. It produces 30 fps CFR video,
uses a conventional 30 kHz track timebase, closes GOPs, caps bitrate, and scales
landscape/portrait footage within 1920x1080 or 1080x1900:
bun skills/pocketjs-video-outro/scripts/make-outro.ts -i ~/Downloads/clip.mov --x
For X-mode verification, both reported rates must be 30/1, the timebase must be
1/30000, and the dimensions must stay within the orientation-aware 1080p bounds:
ffprobe -v error -select_streams v:0 \
-show_entries stream=width,height,r_frame_rate,avg_frame_rate,time_base \
-of default=nw=1 ~/Downloads/clip_outro_x.mp4
Then verify visually — extract a frame near the end and eyeball the card, and
confirm the tail is silent while the body kept its audio (the animation is the
whole point; always look):
V=~/Downloads/clip_outro.mp4
ffmpeg -y -sseof -0.6 -i "$V" -frames:v 1 /tmp/card.png
ffmpeg -nostats -sseof -2 -i "$V" -af volumedetect -f null - 2>&1 | grep mean_volume
ffprobe -v error -select_streams v:0 \
-show_entries stream=color_range,color_space,color_transfer,color_primaries \
-of default=nw=1 "$V"
Options
| Flag | Default | Purpose |
|---|
-i / --input | — (required) | input video |
-o / --output | <input>_outro.mp4 next to input | output path (_outro_x.mp4 with --x) |
--tagline | Bare Metal Modern Web | hero line (wraps on narrow/portrait frames) |
--brand | PocketJS | wordmark next to the glyph |
--url | pocketjs.dev | footer line; pass --url "" to hide it |
--outro | 5.5 | end-card length in seconds |
--xfade | 0.8 | crossfade length; text entrance keys off it |
--crf / --preset | 18 / medium | x264 quality/speed |
--x / --x-compatible | off | emit an X-safe 30fps CFR social upload |
How it adapts to the input
- Probes width/height/fps/duration; VFR inputs use
avg_frame_rate rather than
treating r_frame_rate as the real cadence. The selected rational rate is passed
directly to FFmpeg, avoiding floating-point timebases.
- By default the card uses the source's native resolution and selected frame rate.
--x switches to 30 fps CFR and orientation-aware 1920x1080/1080x1900 bounds.
- Display-matrix rotation is applied to the probed dimensions before rendering the
card, matching FFmpeg's default autorotation for portrait phone footage.
- Color: SDR inputs keep the existing path. HLG and PQ inputs (including the
HLG base layer in iPhone Dolby Vision clips) are tone-mapped to 8-bit BT.709 SDR;
Dolby Vision metadata is intentionally not carried into the shareable H.264 file.
- Type scales with
scale = min(W,H)/1080, so 720p, 1080p, and 4K all look
proportional. On portrait/narrow frames the tagline wraps to two lines (verified).
- Audio: maps the source's first audio stream (
0:a:0) and downmixes to
stereo. This is deliberate — iPhone .mov captures carry an extra multi-channel
spatial-audio track plus several data streams; a:0 is the standard stereo mix.
If the source has no audio, the output is video-only.
Publishing a video on pocketjs.dev
There is no separate upload step — videos ship with the site deploy as
static Worker assets (Cloudflare, site/wrangler.jsonc; keep each file well
under the 25 MiB per-asset limit):
- Commit the mp4 into git at
site/assets/<name>.mp4 (existing examples:
pocketjs-hardware-demo.mp4, pocketjs-demo-wall.mp4).
- Add a
copy(SITE + "assets/<name>.mp4", "assets/<name>.mp4") line in
site/build.ts step 4, next to the other mp4 copies. Files under
site/assets/blog/ (e.g. poster frames) are directory-copied
automatically and need no explicit line.
- Embed with a raw
<video> tag in the page or post markdown. House
classes: class="w-full rounded-xl border border-line". Silent loops use
autoplay muted loop playsinline; anything with a soundtrack must use
controls playsinline preload="metadata" plus a poster (browsers block
un-muted autoplay). Bake the poster with
ffmpeg -ss <t> -i in.mp4 -frames:v 1 -q:v 3 site/assets/blog/<name>-poster.jpg.
- Verify locally with
bun run site:build, then merge to main —
.github/workflows/deploy.yml runs site:build and
bunx wrangler deploy -c site/wrangler.jsonc. Manual deploy is those same
two commands.
Customization & internals
- The card is
assets/outro.html, parameterized via query string
(?layer=…&scale=…&brand=…&tagline=…&url=…). Edit it to restyle; every dimension
is in rem and the script sets root font-size to 10px * scale.
- Entrance/animation is entirely in
ffmpeg, orchestrated by tools/make-outro.ts
(Bun TypeScript, import { $ } from "bun"): each element is screenshotted as its
own transparent layer with the others kept in place via visibility: hidden
(so absolute positions never shift), then composited with per-layer fade (alpha)
overlay (ease-out slide). Change slide distances, stagger, or easing there.
- Keep new wrappers/tooling for this skill in Bun TypeScript — do not add
.sh
scripts (repo convention; see also pocketjs-gba-imagegen).
- To preview just the card without a video, screenshot the template directly:
"<chrome>" --headless --screenshot=card.png --window-size=1920,1080 "file://$PWD/skills/pocketjs-video-outro/assets/outro.html".
Gotchas
- Chrome's headless screenshot honors
--force-device-scale-factor=1 and
--default-background-color=00000000; the text layers rely on that alpha to
composite. Don't drop those flags.
- Keep the output
yuv420p + +faststart (already set) — some players choke on
4:4:4 or non-faststart MP4s.
xfade needs both sides normalized to identical size/fps/sar/pix_fmt; the graph
does this. If you feed a variable-frame-rate capture, the fps filter conforms it.
- ReplayKit and other VFR captures can report a high
r_frame_rate that represents
their smallest frame interval, not their real cadence. Keep average-rate priority
and the original rational expression when changing probe logic.
- Do not remove the explicit sRGB-to-BT.709 conversion from the card layers when
changing HDR handling. Retagging Chrome's SDR PNG values as HLG/PQ is not a valid
color conversion and makes the card shift on an HDR-aware display.
- The tagline on a very wide single line can approach the frame edge; it has
max-width: 92vw and will wrap before overflowing.