@maccesar/aiskills 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ # Publishing metadata handoff
2
+
3
+ Read this after a final technical-demo video and external caption file exist, or when the user asks for social-platform metadata.
4
+
5
+ ## Deliverable
6
+
7
+ Create one upload-ready `<slug>-publishing-metadata.md` in the episode root. The root then contains exactly three regular files:
8
+
9
+ - `<slug>-final.mp4`
10
+ - `<slug>-subtitles-<language>.srt`
11
+ - `<slug>-publishing-metadata.md`
12
+
13
+ Keep research notes and intermediate metadata drafts in `production/`, not in the episode root.
14
+
15
+ ## Required document sections
16
+
17
+ 1. **Shared identity:** final filenames, content/original-language setting, primary search phrase, supporting phrases, one-sentence summary, canonical project link, and cover/thumbnail text.
18
+ 2. **YouTube:** copy-ready title, description, hashtags, Studio tags, thumbnail copy, pinned comment, playlist/category/language/audience/caption settings, and whether chapters are useful.
19
+ 3. **TikTok:** copy-ready organic caption, focused hashtags, cover text, searchable phrases, accessibility setting, and a format warning when the supplied master is not 9:16.
20
+ 4. **Instagram Reels:** copy-ready caption, focused hashtags, cover text, searchable phrases, accessibility setting, and the same format warning when relevant.
21
+ 5. **Publishing checklist:** verify spelling, links, caption timing, public visibility, cover crop, safe zones, and platform preview before posting.
22
+
23
+ ## Writing rules
24
+
25
+ - Match the video's language unless the user requests localized variants.
26
+ - Preserve the project's subtitle typography convention independently from pronunciation text. Define a small lexicon in the production plan when product names, acronyms, commands, paths, or URLs need different written and spoken forms. Captions retain canonical spelling; voice-generator input uses only the pronunciation form approved for that provider.
27
+ - Put the primary phrase naturally in the first title/description lines. Do not stuff keywords or paste ordinary tags into prose.
28
+ - Keep hashtags directly relevant. For YouTube, use a small focused set; more than 60 hashtags are ignored. Studio tags mainly help with misspellings, so include product spelling variants there rather than bloating the description.
29
+ - Treat platform-specific copy as separate even when the central message is shared. TikTok and Instagram organic posts use a caption rather than YouTube's separate title and long description.
30
+ - Do not invent social handles, sponsorships, partnerships, locations, playlist IDs, or trending hashtags. Recommend checking current trends immediately before publishing.
31
+ - Do not automatically crop or reframe a horizontal master. State that TikTok and Reels favor vertical 9:16 creative and offer a separate derivative when requested.
32
+ - For short videos whose useful sections cannot meet YouTube chapter requirements, explicitly omit chapters rather than creating misleading timestamps.
33
+
34
+ ## Freshness and sources
35
+
36
+ Platform behavior and limits are unstable. Browse current official platform documentation whenever generating or materially revising the metadata file. Prefer YouTube Help, TikTok Business/Creative Center, and Meta/Instagram Help or Meta for Business. Link the exact official pages used in a short Sources section inside the metadata document.
@@ -0,0 +1,40 @@
1
+ # macOS and VS Code recording workflow
2
+
3
+ Use this reference only when the accepted plan will control VS Code or other Mac applications.
4
+
5
+ ## Preparation
6
+
7
+ Keep preparation out of the capture unless it is part of the story. Open a uniquely identifiable project window, stabilize its size and position, arrange Explorer/editor/terminal panels, close unrelated tabs, and prepare simulators or devices before starting `screencapture`.
8
+
9
+ Use [vscode-default-profile.md](vscode-default-profile.md) to define a baseline for the current workstation. Reuse an approved profile across projects instead of experimenting with panel commands or coordinates in each episode. Recalibrate only after a display, VS Code layout, keybinding, or global editor-association change.
10
+
11
+ On VS Code, use full project-relative paths with Quick Open. Keep terminal commands readable and execute them from the project root without exposing temporary absolute paths. Prefer stable commands and accessibility selectors. When coordinates are unavoidable, store them as configurable logical-display values and validate display/window geometry first.
12
+
13
+ Choose a dedicated, short copies directory before generating the recorder. Record that choice in the recipe or set `TECHNICAL_DEMO_COPIES_ROOT`; the reusable runtime otherwise uses `~/TechnicalDemos`. Place each disposable project directly beneath it with a short runtime slug such as `build-assets.a1b2c3`. Keep helpers and diagnostic screenshots in a separate system-temp directory. This makes printed absolute paths legible, keeps the Desktop clean, and lets `finally` remove only the current run's resources.
14
+
15
+ A fresh disposable folder prevents stale project tabs and generated files, but it does not guarantee a fresh panel height: VS Code can inherit non-maximized panel geometry across windows. Detect and verify the actual divider position as described in [vscode-default-profile.md](vscode-default-profile.md); do not treat a hard-coded starting `y` coordinate as part of the profile.
16
+
17
+ ## Safe targeting
18
+
19
+ Before every AppleScript or UI action block:
20
+
21
+ 1. find the window by the current run's unique slug;
22
+ 2. raise that exact window;
23
+ 3. make its owning process frontmost;
24
+ 4. re-read the front window title;
25
+ 5. abort unless it still contains the slug;
26
+ 6. only then send the intended action.
27
+
28
+ Never assume activating an application preserves the correct window. Never paste a generated temporary slug into permanent source; create it at runtime.
29
+
30
+ ## Capture and events
31
+
32
+ Use macOS `screencapture` for actual video, not a sequence of screenshots. Record `recording_started`, each visual transition, command submission/completion, relevant output, and `recording_stopped` with monotonic timestamps. Wait for observable completion such as a process exit, file creation, simulator state, or expected UI rather than an arbitrary long sleep.
33
+
34
+ Stop capture cleanly before closing the demonstration window. Close the full project window afterward so the user can see that interaction is finished. Cleanup must target only resources created by the current run.
35
+
36
+ Afterward, verify only that the expected media/log files exist and the temporary project is gone. Ask the user how the live take looked before spending time or tokens on screenshots, contact sheets, frame-by-frame review, transcription, or narration. Use deeper inspection only to investigate feedback or when the user asks for it.
37
+
38
+ ## User coordination
39
+
40
+ Before capture, announce with `🎬` in a separate commentary update and ask the user not to touch input devices. Do not surface permission dialogs during the recording; preflight permissions before the announcement. If a permission or login prompt appears unexpectedly, reject the take, stop safely, and explain what must be prepared.
@@ -0,0 +1,37 @@
1
+ # Story and direction criteria
2
+
3
+ Read this when turning a conversational brief into the written proposal or revising a take.
4
+
5
+ ## Build the visual argument
6
+
7
+ A technical demonstration normally has four movements:
8
+
9
+ 1. **Context:** show the smallest amount of source material needed to understand the task.
10
+ 2. **Action:** type and execute the exact approved command or UI operation at a readable pace.
11
+ 3. **Proof:** show terminal completion and representative output that substantiates every promise.
12
+ 4. **Finish:** hold the clearest result long enough for comprehension and a clean edit.
13
+
14
+ This is a decision framework, not a forced duration or marketing formula. Omit any movement the user's goal does not need.
15
+
16
+ ## Direct the screen
17
+
18
+ - Start from a deliberately composed frame, not application-launch clutter.
19
+ - Keep the pointer parked when it is not communicating something.
20
+ - Make text, previews, terminal height, and simulator/device content legible at delivery resolution.
21
+ - Prefer one decisive action over exploratory menu movement.
22
+ - Show representative results rather than every file when repetition adds no proof. Show every platform or output family that narration explicitly claims.
23
+ - Use an observable exit condition for builds and long-running commands. Avoid dramatic pauses or arbitrary sleeps.
24
+
25
+ ## Write for voice
26
+
27
+ - Give the viewer information they cannot get merely by reading the screen.
28
+ - Keep each line short enough for its measured visual interval.
29
+ - Pronounce command names and symbols naturally for the selected voice service.
30
+ - Preserve the user's approved spelling and pronunciation lexicon across every episode. Do not split an acronym into space-separated letters unless the user explicitly approved that rendering for the selected voice.
31
+ - Do not synchronize final narration against estimated timing. In capture-first mode, exact wording follows the accepted capture and event log. In audio-first mode, approve the exact wording first, measure the returned audio, and use those measurements to lock the visual timing before capture.
32
+ - Use normal punctuation and paragraph breaks for a conversational voice. Do not add synthetic pause tags unless the user explicitly asks for them.
33
+ - Leave captions selectable unless the user asks for burned-in text.
34
+
35
+ ## Edit and review
36
+
37
+ Reject a take when focus lands in the wrong control, text enters a source file, the command is obscured, proof is missing, a permission dialog appears, or dead time materially harms pacing. Correct the permanent recorder and create a new numbered take. Do not conceal a broken workflow with edits that misrepresent what happened.
@@ -0,0 +1,24 @@
1
+ # Vertical and social-media derivatives
2
+
3
+ Read this only when the user asks for a TikTok, YouTube Shorts, Instagram Reels, or other vertical/social deliverable.
4
+
5
+ ## Preserve a clean master
6
+
7
+ Keep the approved horizontal capture and clean audio as source masters. Produce vertical framing, split-screen layouts, dynamic captions, and platform-specific safe areas as separate derivatives so one social format never damages the reusable master.
8
+
9
+ ## Choose the composition deliberately
10
+
11
+ Prefer these approaches in order:
12
+
13
+ 1. record a native `9:16` composition when the workflow can be staged legibly in that space;
14
+ 2. build a deterministic split-screen composition for programming demos, such as code/terminal on one half and simulator, game, animation, narrator, or result on the other;
15
+ 3. reframe a horizontal master with explicit timed points of interest;
16
+ 4. use automatic subject tracking only when the visual target genuinely moves unpredictably.
17
+
18
+ AI is useful but not inherently required. Deterministic crops can follow recorder events, cursor coordinates, editor focus, simulator bounds, or authored keyframes. AI becomes useful for face/object tracking, automatic highlight selection, transcription, word-level caption timing, speaker detection, and semantic layout decisions.
19
+
20
+ ## Captions and safe areas
21
+
22
+ Dynamic word- or phrase-level captions are often burned into social derivatives because viewers may watch muted and platforms overlay their own controls. They must remain optional deliverables: keep the clean master and external SRT, then create a separately named captioned vertical version only when requested.
23
+
24
+ Use the target platform's current resolution, duration, and safe-area rules at production time; verify those rules from official platform documentation because they can change.
@@ -0,0 +1,49 @@
1
+ # VS Code recording profile
2
+
3
+ Use this reference to calibrate a workstation once, save the accepted values with the production package, and reuse them across projects and retakes. The numbers below are an example profile for a 4K display, not universal defaults.
4
+
5
+ ## Values the profile owns
6
+
7
+ - capture display number and physical resolution;
8
+ - logical display size and minimum VS Code window size;
9
+ - whether an auxiliary panel must be hidden and the shortcut that does it;
10
+ - whether Explorer remains visible and on which side;
11
+ - whether Welcome is disabled before recording;
12
+ - terminal divider detection range, drag coordinate, target height, and tolerance;
13
+ - terminal focus shortcut after an editor takes focus;
14
+ - file-preview associations needed by the demonstration.
15
+
16
+ Keep these values in the declarative recipe or pass an equivalent profile to `prepare_default_vscode()`. Do not spread coordinates and shortcuts across project-specific action code.
17
+
18
+ ## Example 4K profile
19
+
20
+ - Physical capture: 3840 × 2160.
21
+ - Logical display: 1920 × 1080.
22
+ - VS Code minimum: 1850 × 1000 logical points.
23
+ - Terminal divider drag: logical `x=645` to `y=720`, verified within 8 points.
24
+ - Terminal open delay: about 1.5 seconds before divider detection.
25
+ - Main Explorer remains visible; unrelated auxiliary panels and Welcome are removed before capture.
26
+
27
+ Recalibrate those values for a different display, scaling mode, VS Code layout, keybinding, or panel placement.
28
+
29
+ ## Integrated terminal preparation
30
+
31
+ 1. Open a uniquely identifiable disposable project in a new VS Code window.
32
+ 2. Apply the profile's panel state and close unrelated tabs before capture.
33
+ 3. Use `Terminal` → `New Terminal` after targeting that exact window.
34
+ 4. Wait for the panel to finish opening.
35
+ 5. Capture a temporary screenshot and detect the real horizontal divider across several columns. A fresh folder does not guarantee a fresh panel height.
36
+ 6. Drag the detected separator to the profile's target height.
37
+ 7. Capture the layout again and verify the divider is within the configured tolerance. Abort before recording if detection fails; never guess the starting coordinate.
38
+ 8. Store diagnostics under the run's temporary helper directory so cleanup removes them.
39
+ 9. Leave the terminal open when preparation finishes.
40
+
41
+ Do not resize with repeated Command Palette steps: they are slower, visible, and dependent on unknown starting height. Do not send the terminal-focus shortcut immediately after creating the terminal because a toggle-style binding may close it. Use it later, after an editor or preview has taken focus and immediately before typing the command.
42
+
43
+ ## Preview behavior
44
+
45
+ If a source format needs a visual preview, configure and preflight the VS Code association before capture. Open the file directly into that preview during the take. Do not expose the editor-selection menu or change a user setting inside the demonstrated project merely for recording.
46
+
47
+ ## Recording boundary
48
+
49
+ All profile preparation happens before capture. The first recorded action belongs to the approved story. Save a newly accepted profile instead of rediscovering the same coordinates during every episode.
@@ -0,0 +1,42 @@
1
+ # YouTube master quality gate
2
+
3
+ Read this before promoting an approved edit to `<slug>-final.mp4` or creating
4
+ its YouTube upload manifest.
5
+
6
+ An MP4 can report 3840×2160 and still be a poor delivery master. Do not treat
7
+ resolution or successful muxing as sufficient. A stream-copied synchronization
8
+ preview remains a preview.
9
+
10
+ For horizontal 4K screen demonstrations using an approved capture profile, create
11
+ the final upload master with:
12
+
13
+ - MP4, H.264 High Profile, progressive, yuv420p;
14
+ - 3840×2160 with no baked-in letterboxing;
15
+ - constant 30 FPS unless the approved content genuinely needs its native 60 FPS;
16
+ - BT.709 primaries, transfer, and matrix metadata;
17
+ - AAC stereo audio at 48 kHz and 384 kbps;
18
+ - the moov atom at the front (`faststart`);
19
+ - a deliberate variable bitrate. The bundled helper uses the lower edge of
20
+ YouTube's current SDR upload ranges: 35 Mbps for 4K at 24–30 FPS and 53 Mbps
21
+ for 4K at 48–60 FPS. It also selects the documented standard/high-frame-rate
22
+ defaults for 1440p, 1080p, and 720p.
23
+
24
+ These bitrate and codec recommendations can change. Before creating a delivery
25
+ master, verify them against YouTube's current official
26
+ [recommended upload encoding settings](https://support.google.com/youtube/answer/1722171).
27
+ If the published range changed, pass its lower bound with `--video-bitrate` and
28
+ record the source and chosen value in the production plan.
29
+
30
+ Run:
31
+
32
+ ```bash
33
+ python3 scripts/normalize_youtube_master.py APPROVED_EDIT.mp4 FINAL.mp4
34
+ ```
35
+
36
+ The tool refuses to overwrite the output and verifies resolution, CFR, codec,
37
+ profile, pixel format, BT.709, audio, container, and bitrate before completing.
38
+ Do not create an upload manifest when this gate fails.
39
+
40
+ After upload, keep the video private until the intended high-resolution
41
+ representations are actually available. YouTube chooses the playback codecs;
42
+ the upload API cannot request AVC, VP9, or AV1 and offers no reprocess action.
@@ -0,0 +1,126 @@
1
+ # Automated YouTube publishing
2
+
3
+ Read this only when the user wants to automate YouTube upload or post-upload configuration.
4
+
5
+ ## Official capability and boundary
6
+
7
+ Google does not provide a general-purpose official `youtube` CLI for this workflow. Use the official YouTube Data API v3, preferably through Google's supported client libraries. The reusable `scripts/youtube_publish.py` wrapper covers:
8
+
9
+ 1. `videos.insert`: resumable video upload plus title, description, tags, category, language, privacy, audience declaration, embedding, license, and optional schedule.
10
+ 2. `playlistItems.insert`: add the returned video ID to one explicitly supplied playlist ID.
11
+ 3. `captions.insert`: upload the timed SRT as a named language track.
12
+ For a corrected SRT on an existing video, `captions.update` replaces the
13
+ media of the exact track recorded in the upload receipt.
14
+ 4. `thumbnails.set`: upload an optional custom PNG or JPEG.
15
+
16
+ YouTube Studio settings that are not exposed by these resources still require manual configuration. Do not promise automation for an undocumented Studio control.
17
+
18
+ ## One-time account setup
19
+
20
+ The user must perform or authorize these steps:
21
+
22
+ 1. Create or select a Google Cloud project.
23
+ 2. Enable YouTube Data API v3.
24
+ 3. Create an OAuth 2.0 client for a Desktop application and download its client-secret JSON.
25
+ 4. Complete the browser consent flow on the first executed upload.
26
+ 5. Identify the expected YouTube channel ID. The upload script verifies that the OAuth token exposes this exact channel before any mutation.
27
+ 6. Choose either one exact playlist ID or explicitly choose no playlist. List or resolve playlists only after OAuth; never infer an ID from a playlist name.
28
+
29
+ When the channel or playlist IDs are unknown, inspect the authenticated account without uploading:
30
+
31
+ ```bash
32
+ python3 scripts/youtube_publish.py --inspect-account \
33
+ --client-secrets /secure/path/client_secret.json \
34
+ --token /secure/path/youtube-token.json
35
+ ```
36
+
37
+ Present the returned channel IDs/titles and playlist IDs/titles to the user. Account inspection is read-only, but starting OAuth or accessing an external account still requires authorization. Do not select a channel or playlist on the user's behalf.
38
+
39
+ Use the `https://www.googleapis.com/auth/youtube.force-ssl` scope when one workflow must upload video, manage playlists, and upload captions. Store the refresh token outside the source repository with owner-only permissions. Never commit the client-secret JSON, token, or upload receipt containing private operational data.
40
+
41
+ API projects created after July 28, 2020 that have not passed YouTube's compliance audit can upload only private videos. Treat that as a platform restriction, not a script failure.
42
+
43
+ ## Safe publication contract
44
+
45
+ - Before generating the manifest, obtain approval for the expected channel, playlist or explicit no-playlist choice, privacy/schedule, captions, and thumbnail.
46
+ - Generate `production/<slug>-youtube-upload.json` from those approved decisions.
47
+ - Use paths relative to the manifest. The video and SRT normally point one directory upward to the episode root.
48
+ - Keep `privacyStatus` as `private` by default.
49
+ - A public, unlisted, or scheduled publication requires explicit user authorization and the script's `--allow-public` switch.
50
+ - Run without `--execute` first. Dry-run validates files and prints the expected channel, explicit playlist choice, privacy/schedule, captions, thumbnail, hashes, intended mutations, and a confirmation token without authenticating or calling YouTube.
51
+ - Present that dry run and wait for separate upload authorization. Pass its exact token through `--confirm-plan`; any file or metadata change invalidates it and requires a new dry run.
52
+ - After OAuth and before any mutation, compare the authenticated channel with `expectedChannelId` and verify that any selected playlist exists on that channel. Abort on a mismatch.
53
+ - On execution, write `production/<slug>-youtube-upload-receipt.json` immediately after `videos.insert`, then update it after playlist, caption, and thumbnail operations. A retry reuses the recorded video ID and skips completed stages.
54
+ - Verify the returned video ID, URL, playlist insertion, caption-track ID, processing state, and intended privacy status before declaring publication complete.
55
+ - Do not delete or replace an existing YouTube video automatically. Stop and request direction when metadata or file hashes disagree with an existing receipt.
56
+
57
+ ## Manifest shape
58
+
59
+ ```json
60
+ {
61
+ "schemaVersion": 1,
62
+ "videoFile": "../03-semantic-final.mp4",
63
+ "captionsFile": "../03-semantic-subtitles-en.srt",
64
+ "thumbnailFile": null,
65
+ "title": "Example title",
66
+ "description": "Example description",
67
+ "tags": ["Example Product", "Technical Tutorial"],
68
+ "categoryId": "28",
69
+ "defaultLanguage": "en",
70
+ "expectedChannelId": "UC_EXACT_CHANNEL_ID",
71
+ "privacyStatus": "private",
72
+ "selfDeclaredMadeForKids": false,
73
+ "embeddable": true,
74
+ "license": "youtube",
75
+ "publishAt": null,
76
+ "playlistId": null,
77
+ "caption": {
78
+ "language": "en",
79
+ "name": "English",
80
+ "isDraft": false
81
+ }
82
+ }
83
+ ```
84
+
85
+ `categoryId` `28` is Science & Technology. Keep the ID configurable rather than deriving it from prose.
86
+ `playlistId: null` is an explicit decision to leave the video out of playlists; omitting the field is invalid. All target fields remain user/project data, never skill defaults.
87
+
88
+ ## Usage
89
+
90
+ Dry run requires only Python:
91
+
92
+ ```bash
93
+ python3 scripts/youtube_publish.py production/<slug>-youtube-upload.json
94
+ ```
95
+
96
+ Copy the printed `confirmationToken` only after the user approves that exact dry-run summary.
97
+
98
+ Account inspection and real execution additionally need Google's official Python clients:
99
+
100
+ ```bash
101
+ python3 -m pip install google-api-python-client google-auth-oauthlib google-auth-httplib2
102
+ python3 scripts/youtube_publish.py production/<slug>-youtube-upload.json \
103
+ --client-secrets /secure/path/client_secret.json \
104
+ --token /secure/path/youtube-token.json \
105
+ --confirm-plan CONFIRMATION_TOKEN_FROM_APPROVED_DRY_RUN \
106
+ --execute
107
+ ```
108
+
109
+ To replace an already uploaded caption track without deleting or duplicating
110
+ the video, repeat the command with `--replace-captions`. This requires the
111
+ existing receipt and its exact `captionId`.
112
+
113
+ Add `--allow-public` only after the user approves `unlisted`, `public`, or `publishAt`. Do not use that flag for the normal private-first review workflow.
114
+
115
+ ## Official references
116
+
117
+ - Video upload: https://developers.google.com/youtube/v3/docs/videos/insert
118
+ - Official Python upload guide: https://developers.google.com/youtube/v3/guides/uploading_a_video
119
+ - Authenticated channel lookup: https://developers.google.com/youtube/v3/docs/channels/list
120
+ - Authenticated playlist lookup: https://developers.google.com/youtube/v3/docs/playlists/list
121
+ - Playlist insertion: https://developers.google.com/youtube/v3/docs/playlistItems/insert
122
+ - Caption upload: https://developers.google.com/youtube/v3/docs/captions/insert
123
+ - Caption replacement: https://developers.google.com/youtube/v3/docs/captions/update
124
+ - Custom thumbnail: https://developers.google.com/youtube/v3/docs/thumbnails/set
125
+ - OAuth for installed apps: https://developers.google.com/youtube/v3/guides/auth/installed-apps
126
+ - Quotas: https://developers.google.com/youtube/v3/determine_quota_cost
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ """Convert timestamped recording events into a narration cue skeleton."""
3
+
4
+ import argparse
5
+ import json
6
+ from pathlib import Path
7
+
8
+
9
+ def load_events(path):
10
+ payload = json.loads(path.read_text())
11
+ events = payload.get('events') if isinstance(payload, dict) else payload
12
+ if not isinstance(events, list) or not events:
13
+ raise SystemExit('event log must contain a non-empty event array')
14
+ return payload, events
15
+
16
+
17
+ def main():
18
+ parser = argparse.ArgumentParser()
19
+ parser.add_argument('events_json', type=Path)
20
+ parser.add_argument('output_json', type=Path)
21
+ args = parser.parse_args()
22
+
23
+ payload, events = load_events(args.events_json)
24
+ if args.output_json.exists():
25
+ raise SystemExit(f'refusing to overwrite: {args.output_json}')
26
+
27
+ previous = None
28
+ for event in events:
29
+ if not isinstance(event, dict) or 'name' not in event or 'timestamp_ms' not in event:
30
+ raise SystemExit('each event must contain name and timestamp_ms')
31
+ current = event['timestamp_ms']
32
+ if not isinstance(current, (int, float)):
33
+ raise SystemExit('timestamp_ms values must be numeric and ordered')
34
+ if previous is not None and current < previous:
35
+ raise SystemExit('timestamp_ms values must be numeric and ordered')
36
+ previous = current
37
+
38
+ starts = [event for event in events if event['name'] == 'recording_started']
39
+ if len(starts) != 1:
40
+ raise SystemExit('event log must contain exactly one recording_started event')
41
+ stops = [event for event in events if event['name'] == 'recording_stopped']
42
+ if len(stops) != 1:
43
+ raise SystemExit('event log must contain exactly one recording_stopped event')
44
+
45
+ start_index = events.index(starts[0])
46
+ stop_index = events.index(stops[0])
47
+ if stop_index <= start_index:
48
+ raise SystemExit('recording_stopped must follow recording_started')
49
+
50
+ captured_events = events[start_index:stop_index + 1]
51
+ origin = starts[0]['timestamp_ms']
52
+ normalized = []
53
+ for event in captured_events:
54
+ current = event['timestamp_ms']
55
+ normalized.append({
56
+ 'event': event['name'],
57
+ 'at': round((current - origin) / 1000, 3),
58
+ **({'path': event['path']} if 'path' in event else {})
59
+ })
60
+
61
+ stop = normalized[-1]['at']
62
+ visual = [
63
+ event for event in normalized
64
+ if event['event'] not in {'recording_started', 'recording_stopped'}
65
+ ]
66
+ cues = []
67
+ for index, event in enumerate(visual):
68
+ end = visual[index + 1]['at'] if index + 1 < len(visual) else stop
69
+ cues.append({
70
+ 'id': index + 1,
71
+ 'event': event['event'],
72
+ 'start': event['at'],
73
+ 'end': end,
74
+ 'text': ''
75
+ })
76
+
77
+ result = {
78
+ 'source_events': str(args.events_json),
79
+ 'duration': stop,
80
+ 'events': normalized,
81
+ 'cues': cues
82
+ }
83
+ if isinstance(payload, dict) and payload.get('demo'):
84
+ result['demo'] = payload['demo']
85
+ args.output_json.write_text(json.dumps(result, indent=2) + '\n')
86
+
87
+
88
+ if __name__ == '__main__':
89
+ main()
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """Create and verify a stable YouTube upload master from an approved edit."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from fractions import Fraction
13
+ from pathlib import Path
14
+
15
+
16
+ STANDARD_BITRATES = {
17
+ 2160: {
18
+ 'standard': ('35M', 35_000_000),
19
+ 'high': ('53M', 53_000_000)
20
+ },
21
+ 1440: {
22
+ 'standard': ('16M', 16_000_000),
23
+ 'high': ('24M', 24_000_000)
24
+ },
25
+ 1080: {
26
+ 'standard': ('8M', 8_000_000),
27
+ 'high': ('12M', 12_000_000)
28
+ },
29
+ 720: {
30
+ 'standard': ('5M', 5_000_000),
31
+ 'high': ('7.5M', 7_500_000)
32
+ },
33
+ 480: {
34
+ 'standard': ('2.5M', 2_500_000),
35
+ 'high': ('4M', 4_000_000)
36
+ },
37
+ 360: {
38
+ 'standard': ('1M', 1_000_000),
39
+ 'high': ('1.5M', 1_500_000)
40
+ }
41
+ }
42
+
43
+
44
+ def parse_args():
45
+ parser = argparse.ArgumentParser()
46
+ parser.add_argument('input', type=Path)
47
+ parser.add_argument('output', type=Path)
48
+ parser.add_argument('--fps', type=int, default=30)
49
+ parser.add_argument('--video-bitrate')
50
+ return parser.parse_args()
51
+
52
+
53
+ def probe(path):
54
+ result = subprocess.run([
55
+ 'ffprobe', '-v', 'error', '-show_streams', '-show_format',
56
+ '-of', 'json', str(path)
57
+ ], check=True, capture_output=True, text=True)
58
+ return json.loads(result.stdout)
59
+
60
+
61
+ def stream(payload, kind):
62
+ selected = next(
63
+ (item for item in payload['streams'] if item.get('codec_type') == kind),
64
+ None
65
+ )
66
+ if selected is None:
67
+ raise RuntimeError(f'{kind} stream is required')
68
+ return selected
69
+
70
+
71
+ def parse_bitrate(value):
72
+ suffixes = {'K': 1_000, 'M': 1_000_000}
73
+ suffix = value[-1].upper()
74
+ if suffix in suffixes:
75
+ return int(float(value[:-1]) * suffixes[suffix])
76
+ return int(value)
77
+
78
+
79
+ def default_bitrate(height, fps):
80
+ frame_rate_class = 'high' if fps >= 48 else 'standard'
81
+ for threshold, values in STANDARD_BITRATES.items():
82
+ if height >= threshold:
83
+ return values[frame_rate_class]
84
+ return (
85
+ ('1.5M', 1_500_000) if frame_rate_class == 'high'
86
+ else ('1M', 1_000_000)
87
+ )
88
+
89
+
90
+ def has_faststart(path):
91
+ with path.open('rb') as handle:
92
+ header = handle.read(min(path.stat().st_size, 16 * 1024 * 1024))
93
+ moov = header.find(b'moov')
94
+ mdat = header.find(b'mdat')
95
+ return moov >= 0 and mdat >= 0 and moov < mdat
96
+
97
+
98
+ def validate_master(path, expected_width, expected_height, fps, bitrate):
99
+ payload = probe(path)
100
+ video = stream(payload, 'video')
101
+ audio = stream(payload, 'audio')
102
+ errors = []
103
+ if (video.get('width'), video.get('height')) != (
104
+ expected_width, expected_height
105
+ ):
106
+ errors.append('resolution changed during normalization')
107
+ if video.get('codec_name') != 'h264' or video.get('profile') != 'High':
108
+ errors.append('video must be H.264 High Profile')
109
+ if video.get('pix_fmt') != 'yuv420p':
110
+ errors.append('video pixel format must be yuv420p')
111
+ if any(video.get(key) != 'bt709' for key in (
112
+ 'color_space', 'color_transfer', 'color_primaries'
113
+ )):
114
+ errors.append('video color metadata must be BT.709')
115
+ actual_fps = Fraction(video['avg_frame_rate'])
116
+ if actual_fps != fps:
117
+ errors.append(f'video frame rate must be constant {fps} FPS')
118
+ actual_bitrate = int(video.get('bit_rate', 0))
119
+ if actual_bitrate < round(bitrate * 0.85):
120
+ errors.append(
121
+ f'video bitrate {actual_bitrate} is below the quality floor {bitrate}'
122
+ )
123
+ if audio.get('codec_name') != 'aac':
124
+ errors.append('audio codec must be AAC')
125
+ if int(audio.get('sample_rate', 0)) != 48_000:
126
+ errors.append('audio sample rate must be 48 kHz')
127
+ if int(audio.get('channels', 0)) != 2:
128
+ errors.append('audio must be stereo')
129
+ if 'mp4' not in payload['format'].get('format_name', ''):
130
+ errors.append('container must be MP4')
131
+ if not has_faststart(path):
132
+ errors.append('MP4 moov atom must precede media data for fast start')
133
+ if errors:
134
+ raise RuntimeError('; '.join(errors))
135
+ return {
136
+ 'width': video['width'],
137
+ 'height': video['height'],
138
+ 'fps': float(actual_fps),
139
+ 'videoBitrate': actual_bitrate,
140
+ 'videoCodec': video['codec_name'],
141
+ 'videoProfile': video['profile'],
142
+ 'pixelFormat': video['pix_fmt'],
143
+ 'color': 'bt709',
144
+ 'audioCodec': audio['codec_name'],
145
+ 'audioSampleRate': int(audio['sample_rate']),
146
+ 'fastStart': True
147
+ }
148
+
149
+
150
+ def main():
151
+ args = parse_args()
152
+ source = args.input.resolve()
153
+ output = args.output.resolve()
154
+ if not source.is_file():
155
+ raise RuntimeError(f'input not found: {source}')
156
+ if output.exists():
157
+ raise RuntimeError(f'refusing to overwrite: {output}')
158
+ if output.suffix.lower() != '.mp4':
159
+ raise RuntimeError('output must use an .mp4 extension')
160
+ if args.fps not in {24, 25, 30, 48, 50, 60}:
161
+ raise RuntimeError('fps must be one of 24, 25, 30, 48, 50, or 60')
162
+ for tool in ('ffmpeg', 'ffprobe'):
163
+ if shutil.which(tool) is None:
164
+ raise RuntimeError(f'missing required tool: {tool}')
165
+
166
+ source_payload = probe(source)
167
+ source_video = stream(source_payload, 'video')
168
+ width = int(source_video['width'])
169
+ height = int(source_video['height'])
170
+ bitrate_text, bitrate = default_bitrate(height, args.fps)
171
+ if args.video_bitrate:
172
+ bitrate_text = args.video_bitrate
173
+ bitrate = parse_bitrate(bitrate_text)
174
+ gop = max(1, round(args.fps / 2))
175
+ temporary = output.with_name(f'.{output.stem}.encoding.mp4')
176
+ temporary.unlink(missing_ok=True)
177
+
178
+ command = [
179
+ 'ffmpeg', '-hide_banner', '-y', '-i', str(source),
180
+ '-map', '0:v:0', '-map', '0:a:0?',
181
+ '-vf', f'fps={args.fps},format=yuv420p',
182
+ '-fps_mode', 'cfr', '-c:v', 'libx264', '-preset', 'fast',
183
+ '-profile:v', 'high', '-b:v', bitrate_text,
184
+ '-g', str(gop), '-keyint_min', str(gop), '-sc_threshold', '0',
185
+ '-bf', '2',
186
+ '-x264-params', (
187
+ 'force-cfr=1:colorprim=bt709:transfer=bt709:colormatrix=bt709'
188
+ ),
189
+ '-color_primaries', 'bt709', '-color_trc', 'bt709',
190
+ '-colorspace', 'bt709',
191
+ '-c:a', 'aac', '-b:a', '384k', '-ar', '48000', '-ac', '2',
192
+ '-movflags', '+faststart', str(temporary)
193
+ ]
194
+ try:
195
+ subprocess.run(command, check=True)
196
+ report = validate_master(
197
+ temporary, width, height, args.fps, bitrate
198
+ )
199
+ temporary.replace(output)
200
+ finally:
201
+ temporary.unlink(missing_ok=True)
202
+ print(json.dumps({'output': str(output), **report}, indent=2))
203
+
204
+
205
+ if __name__ == '__main__':
206
+ try:
207
+ main()
208
+ except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as exc:
209
+ print(f'error: {exc}', file=sys.stderr)
210
+ raise SystemExit(1)