@maccesar/aiskills 1.21.1 → 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.
Files changed (24) hide show
  1. package/README.md +97 -83
  2. package/lib/commands/doctor.js +10 -5
  3. package/lib/config.js +4 -4
  4. package/lib/symlink.js +5 -4
  5. package/package.json +2 -1
  6. package/skills/release/SKILL.md +26 -0
  7. package/skills/release/agents/openai.yaml +6 -0
  8. package/{commands/release.md → skills/release/references/workflow.md} +46 -39
  9. package/skills/technical-demo-videos/SKILL.md +146 -0
  10. package/skills/technical-demo-videos/agents/openai.yaml +4 -0
  11. package/skills/technical-demo-videos/evals/evals.json +62 -0
  12. package/skills/technical-demo-videos/references/audio-timing.md +40 -0
  13. package/skills/technical-demo-videos/references/package-contract.md +33 -0
  14. package/skills/technical-demo-videos/references/publishing-metadata.md +36 -0
  15. package/skills/technical-demo-videos/references/recording-workflow.md +40 -0
  16. package/skills/technical-demo-videos/references/story-direction.md +37 -0
  17. package/skills/technical-demo-videos/references/vertical-social-video.md +24 -0
  18. package/skills/technical-demo-videos/references/vscode-default-profile.md +49 -0
  19. package/skills/technical-demo-videos/references/youtube-master.md +42 -0
  20. package/skills/technical-demo-videos/references/youtube-publishing.md +126 -0
  21. package/skills/technical-demo-videos/scripts/events_to_cues.py +89 -0
  22. package/skills/technical-demo-videos/scripts/normalize_youtube_master.py +210 -0
  23. package/skills/technical-demo-videos/scripts/recording_runtime.py +427 -0
  24. package/skills/technical-demo-videos/scripts/youtube_publish.py +517 -0
@@ -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)