Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions .claude/commands/publish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
---
description: Publish a finished video to YouTube
---

# Publish to YouTube

Upload a rendered project to YouTube, auto-filling the metadata from `project.json`.
Wraps `tools/youtube_upload.py` (OAuth 2.0 + Data API v3, resumable upload).

```
project.json + rendered MP4 → metadata draft → dry-run → upload → write back videoId/URL
```

> **One-time setup required.** YouTube uploads need OAuth (not an API key). If the user
> hasn't set this up, point them at `docs/youtube-upload.md` and stop until
> `YOUTUBE_CLIENT_SECRETS_FILE` is in `.env` and `python3 tools/youtube_upload.py --auth`
> has been run once. Don't attempt an upload without a cached token.

## Entry Point

### Step 1: Locate the project and its rendered video

If the user named a project, use it. Otherwise scan for candidates:

```bash
cd /path/to/claude-code-video-toolkit && ls projects/*/project.json
```

Read the chosen `project.json` **defensively** — real files carry `render`, `format`,
and `publish` blocks beyond the `lib/project/types.ts` schema. Resolve the video file:
1. `render.file` if present (e.g. `out/ai-agent-short.mp4`), relative to the project dir.
2. Else scan the project's `out/*.mp4` and pick the most recent.
3. Confirm the resolved path exists and is non-empty before continuing.

If the project's `phase` isn't `complete`, warn the user and confirm they still want to publish.

### Step 2: Assemble metadata (into a `publish` block)

Build a `publish` object and write it back into `project.json` so it's reviewable,
editable, and re-runnable. If a `publish` block already exists, use it as defaults.

| Field | How to derive |
|-------|---------------|
| `title` | Existing `publish.title`, else the hook/title scene's `title`, else the project `name` (humanized). Keep ≤100 chars. |
| `description` | Existing `publish.description`, else auto-draft: a 1–2 line summary from the scene narration/titles + a channel footer (links, hashtags). Keep ≤5000 chars. |
| `tags` | Existing `publish.tags`, else derive 5–12 topical tags from scene titles + the brand. Comma-joined when passed to the tool. |
| `category` | Default `"28"` (Science & Tech) for the SUPERINTELLIGENCE channel; `"22"` (People & Blogs) otherwise. |
| `thumbnail` | Look for `out/thumbnail.*` or `public/thumbnail.*`. If none and the user wants one, offer to generate via `tools/ideogram4.py` (see the `ideogram4` skill). |
| `privacy` | Default **scheduled** — derive a `publishAt` (next morning ~09:00 local in UTC) or ask the user. Fall back to `private` if they decline a schedule. |
| `playlist` | Optional; only if the user has one. |

**Show the assembled metadata to the user and let them edit before uploading.**

### Step 3: Dry-run first (no upload)

```bash
cd /path/to/claude-code-video-toolkit && python3 tools/youtube_upload.py \
--video "projects/NAME/out/video.mp4" \
--title "TITLE" \
--description-file "projects/NAME/.publish-description.txt" \
--tags "tag1,tag2,tag3" \
--category "28" \
--publish-at "2026-06-10T09:00:00Z" \
--thumbnail "projects/NAME/out/thumbnail.png" \
--dry-run --json-out
```

Write the description to a temp file (`--description-file`) rather than passing a long
`--description` on the command line. Parse the JSON: confirm `requestBody` looks right and
`authOk` is `true`. If `authOk` is `false`, surface the auth error and have the user run
`python3 tools/youtube_upload.py --auth` first — do not proceed.

### Step 4: Upload

Re-run the same command **without** `--dry-run`, keeping `--json-out`. Parse the result.

### Step 5: Write back and report

On `success`, merge into the project's `publish` block:
```json
"publish": {
"platform": "youtube",
"videoId": "<id>",
"url": "https://www.youtube.com/watch?v=<id>",
"privacyStatus": "<actual returned status>",
"publishAt": "<scheduled time or null>",
"uploadedAt": "<today ISO date>"
}
```
Append a `sessions[]` entry summarizing the upload, then report to the user:

```
Published to YouTube

Title: <title>
URL: https://www.youtube.com/watch?v=<id>
Privacy: <actual> (requested: <requested>)
Schedule: <publishAt or "—">
```

**If `privacyStatus` came back `private` but you requested public/scheduled**, tell the
user plainly: this is the unverified-app lock — the video uploaded but won't go public
until their Google Cloud OAuth app is verified. They can publish manually in YouTube Studio.

---

## Quick Mode

Direct invocation for experienced users:
```
/publish ai-agent-short
/publish ai-agent-short --privacy unlisted
```
Parse the project name and any privacy/schedule overrides, still show the metadata and
run a dry-run before the real upload.

---

## Tool Reference (`tools/youtube_upload.py`)

| Option | Description |
|--------|-------------|
| `--video, --input` | Path to the video file |
| `--title` | Title (≤100 chars) |
| `--description` / `--description-file` | Description text, or a file (`-` = stdin) |
| `--tags` | Comma-separated tags (combined ≤500 chars) |
| `--category` | Numeric category ID string (default `22`; `28` = Science & Tech) |
| `--privacy` | `private` (default) / `unlisted` / `public` |
| `--publish-at` | ISO8601 UTC schedule, e.g. `2026-06-10T09:00:00Z` (forces private at insert) |
| `--thumbnail` | Custom thumbnail (≤2MB, 1280×720) |
| `--captions` + `--captions-language` | Caption file + language code |
| `--playlist` | Playlist ID |
| `--account` | Channel name namespacing the cached token (default `default`) |
| `--auth` | Interactive login only — cache a token and exit |
| `--dry-run` | Validate + print the request body without uploading |
| `--json-out` | Single machine-readable JSON line on stdout |

---

## Quota & Limits (worth knowing)

- Default API quota is **10,000 units/day**; each upload costs **~1,600 units → ~6 uploads/day**.
- Hitting quota returns HTTP 403 `quotaExceeded` (the tool reports `errorType: "quota"`).
- Custom thumbnails require a channel with a verified phone number.

---

## Error Handling

| Symptom (`errorType`) | Solution |
|-----------------------|----------|
| `auth` | Run `python3 tools/youtube_upload.py --auth` once; if the refresh token expired (7-day Testing limit), re-run `--auth`. |
| `validation` | Fix the flagged field (missing video/title, bad `--publish-at`). |
| `quota` | Daily quota exhausted — wait, or request more in Google Cloud. |
| `upload` / `http` | Transient network/server issue; the tool already retried — try again later. |
| Video uploaded but stuck `private` | Unverified OAuth app lock — verify the app, or publish manually. |

---

## Evolution

This command evolves through use. If something's awkward or missing:
1. Say "improve this" → Claude captures it in `_internal/BACKLOG.md`
2. Edit `.claude/commands/publish.md` → update `_internal/CHANGELOG.md`
- Issues/PRs: `github.com/digitalsamba/claude-code-video-toolkit`
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,22 @@
# ElevenLabs: Premium cloud TTS (pay-per-character, optional)
# ELEVENLABS_API_KEY=your_api_key_here
# ELEVENLABS_VOICE_ID=your_voice_id_here


# --- YouTube upload (tools/youtube_upload.py) ---
# YouTube uploads use OAuth 2.0 (acting on behalf of a channel), NOT an API key.
# One-time setup (see docs/youtube-upload.md for the full walkthrough):
# 1. Google Cloud Console -> create a project -> enable "YouTube Data API v3".
# 2. OAuth consent screen -> External -> add your channel's Google account as a Test user.
# 3. Credentials -> Create OAuth client ID -> Application type "Desktop app".
# 4. Download the client_secret_*.json and point the var below at it (absolute path).
# 5. Log in once (opens a browser): python3 tools/youtube_upload.py --auth
# NOTE: unaudited API projects MAY have public uploads force-locked to private, but
# this mainly affects uploads to OTHER channels — first-party uploads to your own
# channel generally publish public/scheduled fine. "Testing"-mode refresh tokens
# expire after ~7 days (re-run --auth). The tool reports the actual returned privacy.
# YOUTUBE_CLIENT_SECRETS_FILE=/absolute/path/to/client_secret.json
#
# Optional: override where per-account refresh tokens are cached
# (default: <workspace>/_internal/.youtube/, which is gitignored).
# YOUTUBE_TOKEN_DIR=/absolute/path/to/token/cache/dir
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
*.pem
*.key

# YouTube OAuth token cache (cached refresh tokens — long-lived channel-upload secrets)
_internal/.youtube/

# Python
.venv/
venv/
Expand Down
33 changes: 33 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ This is especially critical for background commands where the working directory
| **Project tools** | voiceover, music, music_gen, sfx, sync_timing | During video creation workflow |
| **Utility tools** | redub, addmusic, notebooklm_brand, locate_watermark | Quick transformations on existing videos |
| **Cloud GPU** | image_edit, upscale, dewatermark, sadtalker, qwen3_tts, music_gen, flux2 | AI processing via RunPod or Modal (`--cloud runpod\|modal`) |
| **Publishing** | youtube_upload | Upload a finished render to YouTube (use `/publish` for the guided workflow) |

Utility tools work on any video file without requiring a project structure.

Expand Down Expand Up @@ -348,6 +349,37 @@ python tools/notebooklm_brand.py \

Trims NotebookLM visuals, keeps full audio, bridges with freeze frame, adds branded outro.

### Publishing to YouTube

Upload a finished render to YouTube via the Data API v3. Use the `/publish` command for the
guided workflow (it auto-fills title/description/tags from `project.json`), or call the tool directly.

```bash
# One-time login (opens a browser, caches a refresh token under _internal/.youtube/)
python3 tools/youtube_upload.py --auth

# Upload privately (the safe default)
python3 tools/youtube_upload.py --video out/video.mp4 --title "My video" \
--description-file DESCRIPTION.md --tags "ai,agents,explainer" --json-out

# Schedule a public go-live
python3 tools/youtube_upload.py --video out/video.mp4 --title "My video" \
--publish-at 2026-06-10T09:00:00Z --thumbnail out/thumb.png --json-out

# Validate everything without uploading (also reports auth readiness)
python3 tools/youtube_upload.py --video out/video.mp4 --title "Test" --dry-run --json-out
```

**Setup is OAuth, not an API key** (uploads act on behalf of a channel). See
`docs/youtube-upload.md` for the one-time Google Cloud Console walkthrough and
`YOUTUBE_CLIENT_SECRETS_FILE` in `.env`. Key realities:
- Default quota is 10,000 units/day; `videos.insert` costs ~1,600 units → **~6 uploads/day**.
- **Unaudited API projects *may* have public uploads force-locked to private** — but this
mainly affects uploads to *other* people's channels. First-party uploads (your own project +
channel + account) generally publish public/scheduled fine without the audit. The tool reports
the actual returned privacy. "Testing"-mode refresh tokens expire after ~7 days (re-run `--auth`).
- Cached tokens live in `_internal/.youtube/` (gitignored — they grant channel-upload access).

## Video Production Workflow

1. **Create/resume project** - Run `/video`, choose template and brand (or resume existing)
Expand All @@ -360,6 +392,7 @@ Trims NotebookLM visuals, keeps full audio, bridges with freeze frame, adds bran
8. **Preview** - `npm run studio` in project directory
9. **Iterate** - Adjust timing, content, styling with Claude Code
10. **Render** - `npm run render` for final MP4
11. **Publish** - Run `/publish` to upload the render to YouTube (metadata auto-filled from `project.json`)

## Project Lifecycle

Expand Down
41 changes: 41 additions & 0 deletions _internal/toolkit-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@
"status": "beta",
"created": "2026-03-23",
"updated": "2026-03-23"
},
"publish": {
"path": ".claude/commands/publish.md",
"description": "Publish a finished video to YouTube - auto-fills metadata from project.json, then uploads",
"status": "beta",
"created": "2026-06-09",
"updated": "2026-06-09"
}
},
"tools": {
Expand Down Expand Up @@ -650,6 +657,40 @@
"estimatedCost": "$0.20-0.25 per scene (delegates to ltx2)",
"created": "2026-04-04",
"updated": "2026-04-04"
},
"youtube_upload": {
"path": "tools/youtube_upload.py",
"description": "Upload a rendered video to YouTube via the Data API v3 (OAuth 2.0, resumable upload) — title/description/tags/thumbnail/captions/playlist, private/unlisted/public or scheduled publish",
"usage": "python3 tools/youtube_upload.py --video out/video.mp4 --title \"My video\" --tags \"ai,explainer\" --json-out",
"status": "beta",
"category": "publishing",
"backend": "YouTube Data API v3 (OAuth 2.0 installed-app flow)",
"requires": "Google Cloud OAuth client (Desktop app); YOUTUBE_CLIENT_SECRETS_FILE",
"options": {
"auth": "--auth (interactive login, caches a refresh token per --account)",
"privacy": [
"private",
"unlisted",
"public"
],
"schedule": "--publish-at ISO8601 UTC (forces private at insert)",
"extras": [
"--thumbnail",
"--captions",
"--playlist"
],
"dryRun": true,
"jsonOut": true,
"multiChannel": "--account NAME"
},
"envVars": [
"YOUTUBE_CLIENT_SECRETS_FILE",
"YOUTUBE_TOKEN_DIR"
],
"note": "Uploads require OAuth, not an API key. Default quota 10,000 units/day; videos.insert ~1600 units (~6 uploads/day). Unverified OAuth apps force uploads to private until Google-verified. Cached tokens live in _internal/.youtube/ (gitignored).",
"documentation": "docs/youtube-upload.md",
"created": "2026-06-09",
"updated": "2026-06-09"
}
},
"optionalComponents": {
Expand Down
Loading