Convert diarized transcripts to .vtt (speaker turns + timestamps) and publish them as caption tracks on the matching YouTube videos via the Data API. Outward-facing: gated by an approved dry-run.
Current _data/transcripts/*.yml turns carry only speaker + text — no per-turn start/end timestamps (only chapter-level youtube.chapters[].timestamp mm:ss exists, which is too coarse for cues). Real .vtt cues need per-segment start/end, which the diarized schema from TASK-244 → TASK-247 adds (hence this task’s dep on 247). Do NOT attempt VTT from the current timestamp-less turns; build against the diarized turn schema (speaker segments + start/end).
HH:MM:SS.mmm --> HH:MM:SS.mmm, prefixing the cue text with the speaker name from speaker_map (e.g. Mike Hall: ...). Language en.transcript_id → _data/video_assets.yml item → platforms[youtube].asset_id.captions.list(part=snippet, videoId=<asset_id>) = 50 units — find existing en track.captions.insert(part=snippet, sync=false) with the .vtt body = 400 units — when no en track exists.captions.update = 450 units — replace body of the existing en track (in place, same track id).captions.delete (50) only for cleanup.en): if an en track exists → captions.update that track id; else captions.insert. Re-running with unchanged VTT is a no-op (compare a stored hash of the last-published VTT / fetch+compare). Never creates a second en track.captions.list live tracks, report insert-vs-replace decision + a VTT diff/preview + live-state fingerprint (existing track id + etag). No write. Human approves. Apply job re-verifies the fingerprint (track not changed underneath) then insert/update. Abort on drift.captions.list shows exactly one en track; re-run dry-run → no-op (idempotent replace confirmed).Reads: diarized _data/transcripts/*.yml (post TASK-247), _data/video_assets.yml. Uses bin/lib/youtube_client.rb. VTT written to gitignored scratch, not the repo. No repo writes.