ttdl, browser-native TikTok downloader
A thin browser-harness-js heredoc, exactly like gsearch/xsearch/ytdl.
There is no Bun program, no vendored signer, no HTTP client impersonation.
Every hard thing TikTok does to play a video, the signed CDN URL, the adaptive
quality ramp, the edit list, the page already does for playback. ttdl just
records the result. The capture is the clean, unwatermarked playback stream:
TikTok's in-app/Download button serves a separately rendered watermarked file,
while recording MediaSource gets exactly what the player shows the viewer.
Core Principle
The page plays the video, URL signing, CDN tokens, quality selection are all done by TikTok's own player; ttdl just records the demuxed media the player feeds to MediaSource via a SourceBuffer.appendBuffer hook and muxes it to MP4 with ffmpeg. No yt-dlp, no signature solver, no HTTP client impersonation.
When to Use / NOT
- Use when: the user wants to download, save, or fetch a TikTok video (with or without audio) to disk, full watch URL, short link (
vm.tiktok.com/…), or bare numeric ID. Region-locked and age-gated content work as long as a logged-in tab can play them.
- NOT when: no Chromium-based browser with remote debugging is running or
ffmpeg is not on PATH (the CLI refuses to run). or the target is YouTube (that is the ytdl skill's domain). or the user wants the watermarked in-app Download file (this skill captures the clean playback stream instead).
Workflow
- Prerequisites:
browser-harness-js on PATH, a Chromium browser with remote debugging, ffmpeg on PATH (always required); run bash <skill-dir>/scripts/setup if not set up.
- Invoke:
ttdl "<url-or-id>" [-q best|audio] [-o Name] [-d dir], or --info for title/author/duration/resolution only.
- The script connects to the shared CDP session and injects the MSE hook via
Page.addScriptToEvaluateOnNewDocument before any page JS runs (How it works, step 2).
- It opens the watch URL in a foreground tab, waits for
networkIdle, polls for <video>, and bails on a verify/captcha interstitial (step 3).
- It plays muted from 0 and waits until the watched portion is buffered AND append activity quiesces, then freezes capture + pauses atomically (steps 5–6).
- It picks the largest video + largest audio buffer (the quality ramp creates two MediaSources) and pulls each to disk in 256 KB base64 chunks (steps 7–8).
- ffmpeg muxes
-c copy -t <duration> -movflags +faststart; the tab closes in try/finally (steps 9–10).
ttdl "https://www.tiktok.com/@user/video/7642721752497310989" # best → ~/Downloads
ttdl "https://www.tiktok.com/@user/video/7642721752497310989" -q audio # audio only (.m4a)
ttdl "https://www.tiktok.com/@user/video/7642721752497310989" --info # title / author / duration / resolution
ttdl "https://vm.tiktok.com/ZMxxxxx/" # short link (browser resolves the redirect)
ttdl 7642721752497310989 # bare numeric ID
ttdl "https://www.tiktok.com/@user/video/..." -o Name -d ~/Videos
Quality targets
-q |
what happens |
needs ffmpeg? |
best |
let the player pick (it auto-ramps to the highest quality it offers), capture, mux, trim, faststart |
yes |
audio |
keep only the audio buffer → .m4a |
yes |
There is no -q 720p/1080p on purpose. Unlike YouTube, TikTok's web player
doesn't expose a clean quality API, it auto-selects based on connection and
ramps up (creating a new, higher-quality MediaSource mid-playback; see How it
works). ttdl captures the largest of each kind, so you always get the top quality
the player reached. Forcing a specific resolution isn't reliably possible.
ffmpeg is a muxer + trimmer here, never a re-encoder (-c copy):
- Mux: TikTok serves audio and video as separate SourceBuffers (e.g.
HEVC
hvc1.* video + AAC mp4a.* audio), so they need combining into one file.
- Trim: the raw MSE media is longer than what the player plays (see
"Why trim to the duration").
- Faststart: the captured streams are fragmented MP4;
-movflags +faststart
reorders moov to the front so the file is seekable/streamable everywhere.
The output stays pure (lossless) at TikTok's own codecs, HEVC video + AAC
audio. That pair is QuickTime/iOS-friendly (iOS natively decodes HEVC+AAC), so
unlike ytdl's AV1/Opus output, no re-encode is needed for Apple devices.
How it works
All inside one browser-harness-js <<EOF heredoc, ytdl-style:
- Connect to the browser's shared CDP session (or
session.connect()).
Page.addScriptToEvaluateOnNewDocument, inject the MSE hook before
any page JS runs:
- Wrap
MediaSource.prototype.addSourceBuffer (via Object.defineProperty)
so each SourceBuffer is tagged with its codec and a derived kind.
- On each SourceBuffer, define an OWN
appendBuffer property that records
the bytes (the demuxed ISO-BMFF fragment) before calling the original. An
own property is required, SourceBuffer.prototype.appendBuffer is a
non-writable native method, so reassigning the prototype silently no-ops.
- Kind is derived from the
codecs= fourcc, not the container mime (see
Traps, this is the single biggest TikTok-specific difference from ytdl).
Target.createTarget foreground → Page.navigate to the watch URL →
wait networkIdle, then poll for <video> and bail on a verify/captcha
interstitial. Foreground is required: background tabs have flaky autoplay,
and if the player never starts, MediaSource is never fed.
- Read the author + video id from the final (post-redirect)
location.href
, short links (vm.tiktok.com/…, tiktok.com/t/…) have redirected by now.
- Play muted from
currentTime = 0. Unlike ytdl (16×, because YouTube's
SABR fetches-as-it-plays and playback rate bounds capture), TikTok capture is
append-driven: the player fetches+appends the whole fMP4 up front, so
buffered.end reaches the full duration before currentTime moves at all.
Playback rate is therefore cosmetic for capture, and TikTok's player
resets playbackRate to 1× while the tab is foregrounded, so fighting it is
pointless. ttdl doesn't set playbackRate. It does re-assert muted each
coverage tick: the player can un-mute on a quality switch / re-init and blast
audio mid-capture.
- Wait until the watched portion is buffered AND append activity quiesces
(
buffered.end >= duration-0.5 and total captured bytes unchanged for 2
ticks), then freeze capture + pause atomically. A __capDone flag makes
the hook pass appends through (stop recording) the instant coverage
completes, so TikTok's auto-loop (which re-feeds MSE) doesn't pollute the
captured buffers.
- Pick the largest video + largest audio buffer. The player commonly
creates two MediaSource instances, an initial lower-quality one, then a
higher-quality one when it ramps up, yielding 4 buffers (2 video, 2 audio).
The largest of each kind is the real capture; the small ones are the
abandoned lower-quality pair.
- Pull each to disk in 256 KB (byte-count divisible by 3, so each base64
slice is independently decodable) chunks: page-side
__pullBuffer(i,offset,len)
returns base64; the REPL decodes with Buffer.from(b64,'base64') and
fs.appendFileSyncs it. 256 KB, not 4 MB, see the Traps below: a single
returnByValue frame carrying base64 of a 4 MB slice closes Dia's debug
WebSocket on the first call.
- ffmpeg
-i video -i audio -t <duration> -c copy -movflags +faststart out.mp4
(bash, after the heredoc returns the temp-file paths).
closeTab in try/finally, fire-and-forget, exact ytdl teardown.
Why capture at MediaSource, not the CDN URL
TikTok serves each video from a signed CDN URL
(v16-webapp-prime.tiktok.com/video/tos/…?a=…&bti=…&ft=…&x-expires=…&x-signature=…)
that's minted by the page and tied to the session. The signature is in the URL,
so a naive fetch(url) can work while the page is live, but the URL is
short-lived, the response can be range-limited, and you'd be reverse-engineering
TikTok's player to find it. Capturing at SourceBuffer.appendBuffer sidesteps
all of that: whatever the player fetched, however it signed it, the demuxed bytes
flow through MSE in the clear. This is auth-agnostic, signing-agnostic, and
quality-agnostic, the page did all of it; we record the output. It's literally
"watch it → record it," the same model as ytdl.
Why trim to the duration
TikTok serves an fMP4 that is longer than what the player plays. The
<video>.duration (and seekable/buffered ranges) reflect an edit-listed
playback length, e.g. 27.8 s, while the raw MSE media is ~30.4 s. The player
plays 0 → 27.8 s then loops back to 0; the trailing ~2.6 s is real footage
the player deliberately excludes from the feed. If you capture all the MSE bytes
verbatim (the ytdl model), you get a video ~10% longer than what the user
watched, with a tail they never saw.
So ttdl trims the muxed output to <video>.duration (ffmpeg -t <dur> -c copy). The output matches what the user watched. The quiescence guard
in the coverage latch (step 6) is what makes this safe to do from a capture that
may or may not have grabbed the whole tail: buffered.end >= dur-0.5 guarantees
the watched portion is fully appended regardless of how much tail came through,
and trimming discards whatever tail did.
Files
All paths relative to <skill-dir>.
scripts/ttdl, the bash CLI (a browser-harness-js heredoc, no #!bun)
scripts/setup, symlink ttdl + browser-harness-js onto PATH
- (no
lib/, no solver/client scaffolding)
Traps
- Classify SourceBuffers by the codec in
codecs=, never by the container mime. TikTok puts the audio in a SourceBuffer whose container is video/mp4, only the codecs="mp4a.40.29" param reveals it's audio. A naive mime.startsWith("video") ? "video" : "audio" (the ytdl pattern) tags both TikTok buffers as video, you pick the largest (the real video) and drop the audio buffer as "smaller video", and the capture comes back silent. ttdl parses the fourcc prefix (mp4a→audio, hvc1/avc1/av01→video) and only falls back to the container mime if there's no codecs=. Keep this, it's the one line that makes TikTok capture work.
- The MSE hook must inject via
Page.addScriptToEvaluateOnNewDocument, not a post-load Runtime.evaluate. The player grabs MediaSource/addSourceBuffer references while it boots; injecting before any page JS runs is the only way to patch them in time. The hook runs in the main world (no worldName), where the player lives.
- Patch
appendBuffer as an OWN property on each SourceBuffer instance, never on the prototype. SourceBuffer.prototype.appendBuffer is a non-writable native method, sb.appendBuffer = fn on the prototype silently no-ops; Object.defineProperty(SourceBuffer.prototype, 'appendBuffer', …) fails to take effect. Defining an own property on the instance shadows the prototype method correctly. (addSourceBuffer can be patched on the prototype via defineProperty, it's writable.)
- Use a foreground tab. Background-tab autoplay is unreliable on TikTok; if the player doesn't start, MediaSource is never fed and capture is empty.
createTarget({ url: 'about:blank' }) (no background: true).
- Trim the output to
<video>.duration. TikTok's raw MSE media is longer than the played (edit-listed) duration; without -t <dur> the muxed file has a trailing section the user never saw (see "Why trim to the duration"). The quiescence latch guarantees the watched portion is fully captured before the trim, so this is always safe.
- Coverage is append-driven, but latch on quiescence, not just
buffered.end. TikTok appends the whole fMP4 up front, so capture completes in seconds, but buffered.end caps at the edit-listed duration even as the (longer) raw media keeps appending. Latching on end >= dur-0.5 alone can fire while bytes are still flowing. ttdl additionally requires total captured bytes to be unchanged for 2 consecutive ticks (500 ms) before breaking. The __capDone freeze-on-latch keeps the auto-loop's re-appends out of the buffers.
- Pick the largest buffer of each kind, the player re-inits MediaSource on a quality bump. TikTok ramps quality mid-playback (e.g. HEVC
hvc1.1.6.L186 → hvc1.1.6.L150), creating a second MediaSource with its own audio+video SourceBuffers. You'll see 4 buffers (2 video, 2 audio); the largest of each kind is the real full capture, the small pair is the abandoned lower-quality one. Don't assume one buffer per kind.
- TikTok auto-loops; never rely on
ended. The <video> loops back to 0 instead of firing ended, so an ended-based latch would spin forever. The __capDone flag is what stops the loop's re-appends from doubling the capture. (An ended check is kept as a safety for non-looping edge cases, but the real exit is the quiescence+coverage latch.)
- Re-assert
muted every poll tick (but NOT playbackRate). TikTok's player can un-mute on a quality switch / re-init; a one-shot muted = true at play-start gets clobbered and you'll hear audio mid-capture. The coverage poll re-asserts muted each 250 ms. Don't re-assert playbackRate: capture is append-driven (the whole file is buffered before playback advances), so the rate is cosmetic, and TikTok's player resets playbackRate to 1× on a foregrounded tab anyway, fighting it is pointless. ytdl re-asserts 16× because YouTube coverage is playback-bound (SABR fetches-as-it-plays); ttdl isn't, so the divergence is deliberate.
- Read the title AFTER coverage, with a
data-e2e=video-desc fallback. document.title is the generic "TikTok - Make Your Day" until SRM/hydration finishes (a few seconds in). Reading it right after networkIdle gives the generic title. ttdl reads it after the coverage loop completes, and falls back to the [data-e2e=video-desc] element's text if the title is still generic.
- Detect the "verify you are human" / captcha interstitial and back off. TikTok is aggressive about bot detection. ttdl scans
document.body.innerText for verify|robot|human|captcha|unusual|slide to|puzzle during the player-ready poll and throws a clear error if it hits one. Open the URL once manually in the browser to clear it, then retry, don't hammer it.
- Borrow a logged-in tab for gated content. Region-locked, age-gated, and followers-only videos may require an active TikTok login. ttdl opens its own tab but reuses the browser's cookie jar; if you can watch it in the browser, ttdl can record it.
ffmpeg is always required. Unlike ytdl (where ffmpeg is only needed for HD muxing), ttdl needs it for every download: TikTok serves separate audio+video buffers (mux), the edit-list tail (trim), and fragmented-MP4 capture (faststart). The CLI refuses to run if ffmpeg isn't on PATH. Don't drop -y (overwrite), without it ffmpeg's interactive Overwrite? [y/N] prompt fails non-interactively and leaves a stale file.
- Bytes cross the CDP boundary as base64 in 256 KB (3-aligned) slices via
Runtime.evaluate returnByValue, decoded with Buffer.from(b64,'base64') and appended. The slice size is divisible by 3 so each slice's base64 is independently decodable (no interior = padding). Don't slice at a non-multiple-of-3 offset or the concatenation decodes to garbage.
- 256K, NOT 512K: a 256 KB slice works regardless of buffer size; a 256 KB frame survives on Dia; a 4 MB frame (~5.6 MB of JSON) closes the debug WebSocket on the first call.
This was latent in ttdl because TikTok buffers are usually under 4 MB; any longer or higher-quality HEVC TikTok exceeds it and the first pull slice kills the socket. Keep slices small; throughput is a non-issue at these sizes.
- Output is HEVC + AAC, which is QuickTime/iOS-friendly as-is. TikTok typically serves HEVC (
hvc1) video + AAC (mp4a) audio; -c copy preserves those and iOS/macOS play them natively (no re-encode needed, unlike ytdl's AV1/Opus). If a video comes back as AV1/VP9+Opus and you need QuickTime/iOS, re-encode in one step: ffmpeg -y -i "out.mp4" -c:v libx264 -crf 18 -preset veryfast -pix_fmt yuv420p -c:a aac -b:a 192k "out-qt.mp4".
- No backticks anywhere in the heredoc body. In an unquoted
<<EOF, a lone backtick opens bash command substitution and trips an EOF parse error. Use string concatenation ('...' + var + '...') and double-quoted JS strings, never template literals. Likewise avoid $ in JS (only intentional ${bash_var} interpolations belong) and avoid backslash-regex for URL parsing (use indexOf/slice) so the unquoted heredoc passes it through untouched.
Red Flags
- Classifying SourceBuffers by container mime instead of the
codecs= fourcc, TikTok puts audio in a video/mp4 buffer; mime-based classification yields a silent capture (Traps).
- Post-load hook injection (
Runtime.evaluate) instead of Page.addScriptToEvaluateOnNewDocument; patching appendBuffer on the prototype, a non-writable native method that silently no-ops.
- Background tabs: flaky autoplay means MediaSource is never fed and capture is empty.
- Skipping the trim to
<video>.duration (raw MSE media is longer than what was played); latching on buffered.end alone or on ended (the auto-loop re-feeds MSE).
- Assuming one buffer per kind, the quality ramp re-inits MediaSource, yielding 4 buffers (2 video, 2 audio).
- One-shot
muted = true (the player un-mutes on a quality switch); reading document.title before hydration (generic title).
- Hammering a captcha interstitial; slicing base64 at a non-multiple-of-3 offset or in 4 MB slices (closes the debug WebSocket); dropping
-y from ffmpeg.
- Backticks or template literals anywhere in the unquoted heredoc body.
Verification
- The output file exists at the expected path (
~/Downloads default, or -d) with the -o name.
- The muxed output's duration equals
<video>.duration (the trim applied), not the raw MSE length.
- Audio is present and not silent (codec classification worked);
--info reports the expected title/author/duration/resolution.
- The output stays pure HEVC + AAC (
-c copy, no re-encode), QuickTime/iOS-friendly as-is.
References
N/A, no references/ directory; the skill ships scripts/ttdl + scripts/setup (see Files).
1---2name: ttdl3description: Use when the user wants to download or save a TikTok video (with or without audio) to disk. Browser-native capture, no yt-dlp: the page plays the video and ttdl records the demuxed stream the player feeds to MediaSource, then muxes to MP4 with ffmpeg. Region-locked and age-gated content work with a logged-in tab.4---56# ttdl, browser-native TikTok downloader78A thin `browser-harness-js` heredoc, exactly like `gsearch`/`xsearch`/`ytdl`.9There is **no Bun program, no vendored signer, no HTTP client impersonation**.10Every hard thing TikTok does to play a video, the signed CDN URL, the adaptive11quality ramp, the edit list, the page already does for playback. ttdl just12records the result. The capture is the **clean, unwatermarked** playback stream:13TikTok's in-app/Download button serves a *separately rendered* watermarked file,14while recording MediaSource gets exactly what the player shows the viewer.1516## Core Principle1718The page plays the video, URL signing, CDN tokens, quality selection are all done by TikTok's own player; ttdl just records the demuxed media the player feeds to MediaSource via a `SourceBuffer.appendBuffer` hook and muxes it to MP4 with ffmpeg. No yt-dlp, no signature solver, no HTTP client impersonation.1920## When to Use / NOT2122- **Use when:** the user wants to download, save, or fetch a TikTok video (with or without audio) to disk, full watch URL, short link (`vm.tiktok.com/…`), or bare numeric ID. Region-locked and age-gated content work as long as a logged-in tab can play them.23- **NOT when:** no Chromium-based browser with remote debugging is running or `ffmpeg` is not on PATH (the CLI refuses to run). or the target is YouTube (that is the `ytdl` skill's domain). or the user wants the watermarked in-app Download file (this skill captures the clean playback stream instead).2425## Workflow26271. Prerequisites: `browser-harness-js` on PATH, a Chromium browser with remote debugging, `ffmpeg` on PATH (always required); run `bash <skill-dir>/scripts/setup` if not set up.282. Invoke: `ttdl "<url-or-id>" [-q best|audio] [-o Name] [-d dir]`, or `--info` for title/author/duration/resolution only.293. The script connects to the shared CDP session and injects the MSE hook via `Page.addScriptToEvaluateOnNewDocument` before any page JS runs (How it works, step 2).304. It opens the watch URL in a foreground tab, waits for `networkIdle`, polls for `<video>`, and bails on a verify/captcha interstitial (step 3).315. It plays muted from 0 and waits until the watched portion is buffered AND append activity quiesces, then freezes capture + pauses atomically (steps 5–6).326. It picks the largest video + largest audio buffer (the quality ramp creates two MediaSources) and pulls each to disk in 256 KB base64 chunks (steps 7–8).337. ffmpeg muxes `-c copy -t <duration> -movflags +faststart`; the tab closes in `try/finally` (steps 9–10).3435```bash36ttdl "https://www.tiktok.com/@user/video/7642721752497310989" # best → ~/Downloads37ttdl "https://www.tiktok.com/@user/video/7642721752497310989" -q audio # audio only (.m4a)38ttdl "https://www.tiktok.com/@user/video/7642721752497310989" --info # title / author / duration / resolution39ttdl "https://vm.tiktok.com/ZMxxxxx/" # short link (browser resolves the redirect)40ttdl 7642721752497310989 # bare numeric ID41ttdl "https://www.tiktok.com/@user/video/..." -o Name -d ~/Videos42```4344## Quality targets4546| `-q` | what happens | needs ffmpeg? |47|---------|--------------|---------------|48| `best` | let the player pick (it auto-ramps to the highest quality it offers), capture, mux, trim, faststart | yes |49| `audio` | keep only the audio buffer → `.m4a` | yes |5051There is no `-q 720p`/`1080p` on purpose. Unlike YouTube, TikTok's web player52doesn't expose a clean quality API, it auto-selects based on connection and53*ramps up* (creating a new, higher-quality MediaSource mid-playback; see How it54works). ttdl captures the largest of each kind, so you always get the top quality55the player reached. Forcing a specific resolution isn't reliably possible.5657`ffmpeg` is a **muxer + trimmer** here, never a re-encoder (`-c copy`):58- **Mux:** TikTok serves audio and video as **separate** SourceBuffers (e.g.59 HEVC `hvc1.*` video + AAC `mp4a.*` audio), so they need combining into one file.60- **Trim:** the raw MSE media is *longer* than what the player plays (see61 "Why trim to the duration").62- **Faststart:** the captured streams are fragmented MP4; `-movflags +faststart`63 reorders `moov` to the front so the file is seekable/streamable everywhere.6465The output stays pure (lossless) at TikTok's own codecs, HEVC video + AAC66audio. That pair is QuickTime/iOS-friendly (iOS natively decodes HEVC+AAC), so67unlike ytdl's AV1/Opus output, no re-encode is needed for Apple devices.6869## How it works7071All inside one `browser-harness-js <<EOF` heredoc, ytdl-style:72731. **Connect** to the browser's shared CDP session (or `session.connect()`).742. **`Page.addScriptToEvaluateOnNewDocument`**, inject the MSE hook *before*75 any page JS runs:76 - Wrap `MediaSource.prototype.addSourceBuffer` (via `Object.defineProperty`)77 so each `SourceBuffer` is tagged with its **codec** and a derived **kind**.78 - On each SourceBuffer, define an OWN `appendBuffer` property that records79 the bytes (the demuxed ISO-BMFF fragment) before calling the original. An80 own property is required, `SourceBuffer.prototype.appendBuffer` is a81 non-writable native method, so reassigning the prototype silently no-ops.82 - **Kind is derived from the `codecs=` fourcc, not the container mime** (see83 Traps, this is the single biggest TikTok-specific difference from ytdl).843. **`Target.createTarget` foreground** → `Page.navigate` to the watch URL →85 wait `networkIdle`, then poll for `<video>` and bail on a verify/captcha86 interstitial. Foreground is required: background tabs have flaky autoplay,87 and if the player never starts, MediaSource is never fed.884. **Read the author + video id** from the final (post-redirect) `location.href`89, short links (`vm.tiktok.com/…`, `tiktok.com/t/…`) have redirected by now.905. **Play muted** from `currentTime = 0`. Unlike ytdl (16×, because YouTube's91 SABR fetches-as-it-plays and playback rate bounds capture), TikTok capture is92 **append-driven**: the player fetches+appends the whole fMP4 up front, so93 `buffered.end` reaches the full duration before `currentTime` moves at all.94 Playback rate is therefore **cosmetic** for capture, and TikTok's player95 resets `playbackRate` to 1× while the tab is foregrounded, so fighting it is96 pointless. ttdl doesn't set `playbackRate`. It does re-assert `muted` each97 coverage tick: the player can un-mute on a quality switch / re-init and blast98 audio mid-capture.996. **Wait until the watched portion is buffered AND append activity quiesces**100 (`buffered.end >= duration-0.5` *and* total captured bytes unchanged for 2101 ticks), then **freeze capture + pause atomically**. A `__capDone` flag makes102 the hook pass appends through (stop recording) the instant coverage103 completes, so TikTok's auto-loop (which re-feeds MSE) doesn't pollute the104 captured buffers.1057. **Pick the largest video + largest audio buffer.** The player commonly106 creates **two** MediaSource instances, an initial lower-quality one, then a107 higher-quality one when it ramps up, yielding 4 buffers (2 video, 2 audio).108 The largest of each kind is the real capture; the small ones are the109 abandoned lower-quality pair.1108. **Pull each to disk** in 256 KB (byte-count divisible by 3, so each base64111 slice is independently decodable) chunks: page-side `__pullBuffer(i,offset,len)`112 returns base64; the REPL decodes with `Buffer.from(b64,'base64')` and113 `fs.appendFileSync`s it. 256 KB, not 4 MB, see the Traps below: a single114 `returnByValue` frame carrying base64 of a 4 MB slice closes Dia's debug115 WebSocket on the first call.1169. **ffmpeg** `-i video -i audio -t <duration> -c copy -movflags +faststart out.mp4`117 (bash, after the heredoc returns the temp-file paths).11810. **`closeTab`** in `try/finally`, fire-and-forget, exact ytdl teardown.119120## Why capture at MediaSource, not the CDN URL121122TikTok serves each video from a **signed** CDN URL123(`v16-webapp-prime.tiktok.com/video/tos/…?a=…&bti=…&ft=…&x-expires=…&x-signature=…`)124that's minted by the page and tied to the session. The signature is in the URL,125so a naive `fetch(url)` *can* work while the page is live, but the URL is126short-lived, the response can be range-limited, and you'd be reverse-engineering127TikTok's player to find it. Capturing at `SourceBuffer.appendBuffer` sidesteps128all of that: whatever the player fetched, however it signed it, the demuxed bytes129flow through MSE in the clear. This is auth-agnostic, signing-agnostic, and130quality-agnostic, the page did all of it; we record the output. It's literally131"watch it → record it," the same model as ytdl.132133## Why trim to the duration134135TikTok serves an fMP4 that is **longer** than what the player plays. The136`<video>.duration` (and `seekable`/`buffered` ranges) reflect an **edit-listed**137playback length, e.g. 27.8 s, while the raw MSE media is ~30.4 s. The player138plays 0 → 27.8 s then **loops back to 0**; the trailing ~2.6 s is real footage139the player deliberately excludes from the feed. If you capture all the MSE bytes140verbatim (the ytdl model), you get a video ~10% longer than what the user141watched, with a tail they never saw.142143So ttdl **trims the muxed output to `<video>.duration`** (`ffmpeg -t <dur> -c144copy`). The output matches what the user watched. The quiescence guard145in the coverage latch (step 6) is what makes this safe to do from a capture that146may or may not have grabbed the whole tail: `buffered.end >= dur-0.5` guarantees147the watched portion is fully appended regardless of how much tail came through,148and trimming discards whatever tail did.149150## Files151152All paths relative to `<skill-dir>`.153154- `scripts/ttdl`, the bash CLI (a `browser-harness-js` heredoc, no `#!bun`)155- `scripts/setup`, symlink `ttdl` + `browser-harness-js` onto PATH156- (no `lib/`, no solver/client scaffolding)157158## Traps159160- **Classify SourceBuffers by the codec in `codecs=`, never by the container mime.** TikTok puts the **audio** in a SourceBuffer whose container is `video/mp4`, only the `codecs="mp4a.40.29"` param reveals it's audio. A naive `mime.startsWith("video") ? "video" : "audio"` (the ytdl pattern) tags *both* TikTok buffers as `video`, you pick the largest (the real video) and drop the audio buffer as "smaller video", and the capture comes back **silent**. ttdl parses the fourcc prefix (`mp4a`→audio, `hvc1`/`avc1`/`av01`→video) and only falls back to the container mime if there's no `codecs=`. Keep this, it's the one line that makes TikTok capture work.161- **The MSE hook must inject via `Page.addScriptToEvaluateOnNewDocument`, not a post-load `Runtime.evaluate`.** The player grabs `MediaSource`/`addSourceBuffer` references while it boots; injecting before any page JS runs is the only way to patch them in time. The hook runs in the main world (no `worldName`), where the player lives.162- **Patch `appendBuffer` as an OWN property on each `SourceBuffer` instance, never on the prototype.** `SourceBuffer.prototype.appendBuffer` is a non-writable native method, `sb.appendBuffer = fn` on the prototype silently no-ops; `Object.defineProperty(SourceBuffer.prototype, 'appendBuffer', …)` fails to take effect. Defining an own property on the instance shadows the prototype method correctly. (`addSourceBuffer` *can* be patched on the prototype via `defineProperty`, it's writable.)163- **Use a foreground tab.** Background-tab autoplay is unreliable on TikTok; if the player doesn't start, MediaSource is never fed and capture is empty. `createTarget({ url: 'about:blank' })` (no `background: true`).164- **Trim the output to `<video>.duration`.** TikTok's raw MSE media is longer than the played (edit-listed) duration; without `-t <dur>` the muxed file has a trailing section the user never saw (see "Why trim to the duration"). The quiescence latch guarantees the watched portion is fully captured before the trim, so this is always safe.165- **Coverage is append-driven, but latch on quiescence, not just `buffered.end`.** TikTok appends the whole fMP4 up front, so capture completes in seconds, but `buffered.end` *caps at the edit-listed duration* even as the (longer) raw media keeps appending. Latching on `end >= dur-0.5` alone can fire while bytes are still flowing. ttdl additionally requires total captured bytes to be unchanged for 2 consecutive ticks (500 ms) before breaking. The `__capDone` freeze-on-latch keeps the auto-loop's re-appends out of the buffers.166- **Pick the largest buffer of each kind, the player re-inits MediaSource on a quality bump.** TikTok ramps quality mid-playback (e.g. HEVC `hvc1.1.6.L186` → `hvc1.1.6.L150`), creating a *second* MediaSource with its own audio+video SourceBuffers. You'll see 4 buffers (2 video, 2 audio); the largest of each kind is the real full capture, the small pair is the abandoned lower-quality one. Don't assume one buffer per kind.167- **TikTok auto-loops; never rely on `ended`.** The `<video>` loops back to 0 instead of firing `ended`, so an `ended`-based latch would spin forever. The `__capDone` flag is what stops the loop's re-appends from doubling the capture. (An `ended` check is kept as a safety for non-looping edge cases, but the real exit is the quiescence+coverage latch.)168- **Re-assert `muted` every poll tick (but NOT `playbackRate`).** TikTok's player can un-mute on a quality switch / re-init; a one-shot `muted = true` at play-start gets clobbered and you'll hear audio mid-capture. The coverage poll re-asserts `muted` each 250 ms. Don't re-assert `playbackRate`: capture is append-driven (the whole file is buffered before playback advances), so the rate is cosmetic, and TikTok's player resets `playbackRate` to 1× on a foregrounded tab anyway, fighting it is pointless. ytdl re-asserts 16× because YouTube coverage *is* playback-bound (SABR fetches-as-it-plays); ttdl isn't, so the divergence is deliberate.169- **Read the title AFTER coverage, with a `data-e2e=video-desc` fallback.** `document.title` is the generic `"TikTok - Make Your Day"` until SRM/hydration finishes (a few seconds in). Reading it right after `networkIdle` gives the generic title. ttdl reads it after the coverage loop completes, and falls back to the `[data-e2e=video-desc]` element's text if the title is still generic.170- **Detect the "verify you are human" / captcha interstitial and back off.** TikTok is aggressive about bot detection. ttdl scans `document.body.innerText` for `verify|robot|human|captcha|unusual|slide to|puzzle` during the player-ready poll and throws a clear error if it hits one. Open the URL once manually in the browser to clear it, then retry, don't hammer it.171- **Borrow a logged-in tab for gated content.** Region-locked, age-gated, and followers-only videos may require an active TikTok login. ttdl opens its own tab but reuses the browser's cookie jar; if you can watch it in the browser, ttdl can record it.172- **`ffmpeg` is always required.** Unlike ytdl (where ffmpeg is only needed for HD muxing), ttdl needs it for every download: TikTok serves separate audio+video buffers (mux), the edit-list tail (trim), and fragmented-MP4 capture (faststart). The CLI refuses to run if `ffmpeg` isn't on PATH. Don't drop `-y` (overwrite), without it ffmpeg's interactive `Overwrite? [y/N]` prompt fails non-interactively and leaves a stale file.173- **Bytes cross the CDP boundary as base64** in 256 KB (3-aligned) slices via `Runtime.evaluate` `returnByValue`, decoded with `Buffer.from(b64,'base64')` and appended. The slice size is divisible by 3 so each slice's base64 is independently decodable (no interior `=` padding). Don't slice at a non-multiple-of-3 offset or the concatenation decodes to garbage.174- **256K, NOT 512K:** a 256 KB slice works regardless of buffer size; a 256 KB frame survives on Dia; a 4 MB frame (~5.6 MB of JSON) closes the debug WebSocket on the first call.175 This was latent in ttdl because TikTok buffers are usually under 4 MB; any longer or higher-quality HEVC TikTok exceeds it and the first pull slice kills the socket. Keep slices small; throughput is a non-issue at these sizes.176- **Output is HEVC + AAC, which is QuickTime/iOS-friendly as-is.** TikTok typically serves HEVC (`hvc1`) video + AAC (`mp4a`) audio; `-c copy` preserves those and iOS/macOS play them natively (no re-encode needed, unlike ytdl's AV1/Opus). If a video comes back as AV1/VP9+Opus and you need QuickTime/iOS, re-encode in one step: `ffmpeg -y -i "out.mp4" -c:v libx264 -crf 18 -preset veryfast -pix_fmt yuv420p -c:a aac -b:a 192k "out-qt.mp4"`.177- **No backticks anywhere in the heredoc body.** In an unquoted `<<EOF`, a lone backtick opens bash command substitution and trips an EOF parse error. Use string concatenation (`'...' + var + '...'`) and double-quoted JS strings, never template literals. Likewise avoid `$` in JS (only intentional `${bash_var}` interpolations belong) and avoid backslash-regex for URL parsing (use `indexOf`/`slice`) so the unquoted heredoc passes it through untouched.178179## Red Flags180181- Classifying SourceBuffers by container mime instead of the `codecs=` fourcc, TikTok puts audio in a `video/mp4` buffer; mime-based classification yields a silent capture (Traps).182- Post-load hook injection (`Runtime.evaluate`) instead of `Page.addScriptToEvaluateOnNewDocument`; patching `appendBuffer` on the prototype, a non-writable native method that silently no-ops.183- Background tabs: flaky autoplay means MediaSource is never fed and capture is empty.184- Skipping the trim to `<video>.duration` (raw MSE media is longer than what was played); latching on `buffered.end` alone or on `ended` (the auto-loop re-feeds MSE).185- Assuming one buffer per kind, the quality ramp re-inits MediaSource, yielding 4 buffers (2 video, 2 audio).186- One-shot `muted = true` (the player un-mutes on a quality switch); reading `document.title` before hydration (generic title).187- Hammering a captcha interstitial; slicing base64 at a non-multiple-of-3 offset or in 4 MB slices (closes the debug WebSocket); dropping `-y` from ffmpeg.188- Backticks or template literals anywhere in the unquoted heredoc body.189190## Verification191192- The output file exists at the expected path (`~/Downloads` default, or `-d`) with the `-o` name.193- The muxed output's duration equals `<video>.duration` (the trim applied), not the raw MSE length.194- Audio is present and not silent (codec classification worked); `--info` reports the expected title/author/duration/resolution.195- The output stays pure HEVC + AAC (`-c copy`, no re-encode), QuickTime/iOS-friendly as-is.196197198## References199200N/A, no references/ directory; the skill ships `scripts/ttdl` + `scripts/setup` (see Files).