diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..075d575 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,17 @@ +{ + "name": "capcut-cli", + "image": "mcr.microsoft.com/devcontainers/rust:1-bookworm", + "postCreateCommand": "bash .devcontainer/post-create.sh", + "remoteEnv": { + "TIKTOK_RESEARCH_ACCESS_TOKEN": "${localEnv:TIKTOK_RESEARCH_ACCESS_TOKEN}", + "TWITTER_BEARER_TOKEN": "${localEnv:TWITTER_BEARER_TOKEN}" + }, + "customizations": { + "vscode": { + "extensions": [ + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml" + ] + } + } +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 0000000..6f29d0e --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# One-shot Codespace bootstrap: install ffmpeg, build the CLI, let the CLI +# fetch its own standalone yt-dlp binary via `deps install`. +set -euo pipefail + +sudo apt-get update +sudo apt-get install -y ffmpeg jq + +cargo build --release +./target/release/capcut-cli deps install +./target/release/capcut-cli deps check >/dev/null && echo "deps ok" >&2 + +echo "Run 'make clips' once TIKTOK_RESEARCH_ACCESS_TOKEN and TWITTER_BEARER_TOKEN are set." >&2 diff --git a/.env.example b/.env.example index 50a5a34..d0207af 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,17 @@ # Copy this file to `.env` or export these variables in your shell. # Do not commit real secrets. +# +# None of these are required for the primary manual-URL flow +# (library import + compose). They only affect the optional, API-gated +# discovery path (see "Optional: API-gated discovery" in README.md). -# Required for reliable X discovery. +# Optional: X/Twitter API bearer token. Enables `discover x-clips` and the +# autopilot clip path. Requires the paid Basic tier or higher. TWITTER_BEARER_TOKEN= -# Optional: TikTok Research API client access token for official sound discovery. +# Optional: TikTok Research API client access token. Enables `discover +# tiktok-sounds` and the autopilot sound path. Access is gated and granted +# mainly to qualifying researchers. TIKTOK_RESEARCH_ACCESS_TOKEN= # Optional: control which local browsers yt-dlp should try for X media import. @@ -13,3 +20,8 @@ CAPCUT_X_COOKIE_BROWSERS=chrome,safari,firefox,edge # Optional: set to 1 for extra discovery debugging logs. CAPCUT_DEBUG_DISCOVERY=0 + +# Optional (tests only): override the yt-dlp binary path. Used by the +# end-to-end import→compose integration test to inject a shim. Leave unset +# in production environments. +CAPCUT_YTDLP_PATH= diff --git a/.github/workflows/build-clips.yml b/.github/workflows/build-clips.yml new file mode 100644 index 0000000..3f19250 --- /dev/null +++ b/.github/workflows/build-clips.yml @@ -0,0 +1,121 @@ +name: build-clips + +on: + workflow_dispatch: + inputs: + mode: + description: "urls (primary: caller supplies links) | discovery (optional: requires API tokens)" + type: choice + default: "urls" + options: + - urls + - discovery + # ─── urls-mode inputs ─────────────────────────────────────────── + sound_url: + description: "[urls] Trending sound URL (TikTok music, YouTube, etc.)" + required: false + clip_url_1: + description: "[urls] Source clip URL #1" + required: false + clip_url_2: + description: "[urls] Source clip URL #2" + required: false + clip_url_3: + description: "[urls] Source clip URL #3" + required: false + # ─── discovery-mode inputs ────────────────────────────────────── + query: + description: "[discovery] Topic for X/Twitter clip search" + default: "ai agents" + region: + description: "[discovery] TikTok region code" + default: "US" + window_days: + description: "[discovery] TikTok rolling window (days)" + default: "7" + min_likes: + description: "[discovery] X minimum likes threshold" + default: "1000" + # ─── shared compose knobs ─────────────────────────────────────── + duration: + description: "Output duration per finished clip (seconds)" + default: "15" + resolution: + description: "Output resolution (WxH)" + default: "1080x1920" + +jobs: + build: + runs-on: ubuntu-latest + env: + TIKTOK_RESEARCH_ACCESS_TOKEN: ${{ secrets.TIKTOK_RESEARCH_ACCESS_TOKEN }} + TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN }} + DURATION: ${{ inputs.duration }} + RESOLUTION: ${{ inputs.resolution }} + steps: + - uses: actions/checkout@v4 + + - name: Validate inputs for selected mode + run: | + if [[ "${{ inputs.mode }}" == "urls" ]]; then + for name in sound_url clip_url_1 clip_url_2 clip_url_3; do + val="${{ inputs.sound_url }}${{ inputs.clip_url_1 }}${{ inputs.clip_url_2 }}${{ inputs.clip_url_3 }}" + done + missing="" + [[ -z "${{ inputs.sound_url }}" ]] && missing+=" sound_url" + [[ -z "${{ inputs.clip_url_1 }}" ]] && missing+=" clip_url_1" + [[ -z "${{ inputs.clip_url_2 }}" ]] && missing+=" clip_url_2" + [[ -z "${{ inputs.clip_url_3 }}" ]] && missing+=" clip_url_3" + if [[ -n "$missing" ]]; then + echo "urls mode requires:$missing" >&2 + exit 1 + fi + else + missing="" + [[ -z "${TIKTOK_RESEARCH_ACCESS_TOKEN}" ]] && missing+=" TIKTOK_RESEARCH_ACCESS_TOKEN" + [[ -z "${TWITTER_BEARER_TOKEN}" ]] && missing+=" TWITTER_BEARER_TOKEN" + if [[ -n "$missing" ]]; then + echo "discovery mode requires repo secrets:$missing" >&2 + echo "Add them under Settings → Secrets and variables → Actions." >&2 + exit 1 + fi + fi + + - name: Install ffmpeg and jq + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg jq + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Build capcut-cli + run: cargo build --release + + - name: Install yt-dlp via the CLI's own deps bootstrap + run: ./target/release/capcut-cli deps install + + - name: Compose (urls mode) + if: inputs.mode == 'urls' + env: + SOUND_URL: ${{ inputs.sound_url }} + CLIP_URLS: "${{ inputs.clip_url_1 }} ${{ inputs.clip_url_2 }} ${{ inputs.clip_url_3 }}" + run: ./scripts/build-clips-from-urls.sh + + - name: Discover + compose (discovery mode) + if: inputs.mode == 'discovery' + env: + QUERY: ${{ inputs.query }} + REGION: ${{ inputs.region }} + WINDOW_DAYS: ${{ inputs.window_days }} + MIN_LIKES: ${{ inputs.min_likes }} + run: ./scripts/build-clips.sh + + - name: Upload clips artifact + uses: actions/upload-artifact@v4 + with: + name: clips + path: clips/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b7187a2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: test + +on: + push: + branches: ["**"] + pull_request: + branches: [main] + +jobs: + cargo-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install ffmpeg (required by the compose smoke test) + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: cargo test + run: cargo test --all-targets diff --git a/.gitignore b/.gitignore index 54fce30..1c84ff0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,19 +4,20 @@ !.env.example *.local -# Python -py/.venv/ -py/*.egg-info/ -__pycache__/ -*.pyc -*.pyo -dist/ -build/ -*.egg - -# Library working files +# Library working files (runtime-generated assets) library/.tmp/ -library/clips/ -library/sounds/assets/ library/output/ -library/manifest.json + +# Ignore non-demo asset directories; un-ignore the committed demo fixtures +library/clips/* +!library/clips/clp_demo001 +library/clips/clp_demo001/* +!library/clips/clp_demo001/video.mp4 + +library/sounds/assets/* +!library/sounds/assets/snd_demo001 +library/sounds/assets/snd_demo001/* +!library/sounds/assets/snd_demo001/audio.mp3 + +# Agent-run batch outputs (repo-root /clips, not library/clips) +/clips/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..31660c3 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: build deps clips clean-clips + +BIN := ./target/release/capcut-cli + +build: + cargo build --release + +deps: + $(BIN) deps check + +# Discover trending audio + ranked clips, compose 3 finished MP4s into ./clips. +# Requires TIKTOK_RESEARCH_ACCESS_TOKEN and TWITTER_BEARER_TOKEN in the env. +# Overridable: QUERY, REGION, WINDOW_DAYS, DURATION, RESOLUTION, MIN_LIKES. +clips: build + ./scripts/build-clips.sh + +clean-clips: + rm -rf clips diff --git a/README.md b/README.md index e97f416..1b53da7 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,128 @@ # capcut-cli -An open source, agent-first Rust CLI for discovering source media, managing a local asset library, and composing short-form social clips without touching a timeline. +An open source, agent-first Rust CLI for importing short-form source material, +managing a local asset library, and composing vertical clips without touching a +timeline. ## Status -This repository was rewritten from Python to Rust. The current implementation is the Rust crate in `src/`; the old Python app described by earlier docs is no longer the source of truth. +The honest minimum viable truth is **fresh input in, finished clip out.** -Today the CLI supports: +Given a trending sound URL and one or more source clip URLs, the CLI imports, +normalizes, trims, scales, center-crops, concatenates, and muxes them into a +final MP4 — reliably, locally, and with real bytes end-to-end. -- checking and installing runtime dependencies -- discovering trending TikTok sounds through TikTok Research API with Creative Center fallback -- discovering X/Twitter clips through authenticated API search plus lower-barrier fallback strategies -- importing sounds and clips into a local JSON-backed library -- composing a final MP4 from one sound and one or more clips -- running a one-shot `autopilot` workflow that discovers, imports, and composes automatically +Discovery of trending material exists in the codebase but is scoped down in +the docs: every official path is gated by an external API (TikTok Research, +X/Twitter v2 search) that is either hard to obtain or paywalled, and the +unauthenticated fallbacks are brittle by design. Treat discovery as an +optional convenience on top of the manual-URL spine, not the spine itself. -## Quick start - -```bash -cargo run -- deps check +What's solid today: -# If yt-dlp is missing, download it to ~/.capcut-cli/bin/yt-dlp -cargo run -- deps install - -# Inspect the local asset library -cargo run -- library list - -# Discover trending TikTok sounds -export TIKTOK_RESEARCH_ACCESS_TOKEN=... -cargo run -- discover tiktok-sounds --limit 5 --region US --window-days 7 +- importing sounds and clips from supported URLs into a local library +- composing one final vertical MP4 from one sound and one or more clips +- loudness normalization presets for social, viral, podcast, broadcast +- structured JSON output on stdout; progress logs on stderr +- committed demo library assets so `compose` works immediately after clone +- end-to-end integration test that exercises import → compose with real media -# Reliable X discovery requires a bearer token -export TWITTER_BEARER_TOKEN=... - -# Discover ranked X clips for a topic -cargo run -- discover x-clips --query "ai agents" --limit 5 --min-likes 1000 +## Quick start -# One-shot agent workflow (discover + import + compose) -cargo run -- autopilot --query "ai agents" --duration 15 +Build and verify dependencies: -# Lower-barrier sound strategies for agents -cargo run -- discover tiktok-sounds --strategy library --limit 5 -cargo run -- discover tiktok-sounds --strategy manual-url --sound-url "https://www.tiktok.com/music/_-123" +```bash +cargo build --release +./target/release/capcut-cli deps check -# Lower-barrier clip strategies for agents -cargo run -- discover x-clips --query "ai agents" --strategy guided -cargo run -- discover x-clips --query "ai agents" --strategy library --limit 5 -cargo run -- discover x-clips --query "ai agents" --strategy manual-url --clip-url "https://x.com/user/status/123" +# If yt-dlp is missing, install the standalone binary into ~/.capcut-cli/bin +./target/release/capcut-cli deps install ``` -You can also install the binary locally: +Run the primary flow — import one sound URL plus one or more clip URLs, then +compose: ```bash -cargo install --path . -capcut-cli --help +# 1. Import a trending audio source (TikTok music, YouTube, Instagram, X) +./target/release/capcut-cli library import \ + "https://www.tiktok.com/music/-" --type sound --tags trending + +# 2. Import one or more source clips +./target/release/capcut-cli library import \ + "https://x.com//status/" --type clip --tags source + +# 3. Compose a finished vertical MP4 +./target/release/capcut-cli compose \ + --sound --clip \ + --duration 15 --resolution 1080x1920 --loudness viral ``` -## Requirements +The CLI writes to `library/output/comp_/final.mp4` unless +`--output` is supplied. Asset IDs are returned in each import's JSON envelope +(`.data.id`). -- Rust toolchain for building and running the crate -- `ffmpeg` available on `PATH`, or placed at `~/.capcut-cli/bin/ffmpeg` -- `yt-dlp` available at `~/.capcut-cli/bin/yt-dlp` +A batch script that wraps the above for three clips at once is documented +below under **Batch: three finished clips**. -Notes: +## Requirements -- `capcut-cli deps install` downloads `yt-dlp` automatically for macOS and Linux. -- `capcut-cli deps install` does not install `ffmpeg`; it only verifies whether `ffmpeg` is already available. -- On macOS, `brew install ffmpeg` is the simplest way to satisfy the `ffmpeg` requirement. -- Reliable X/Twitter media import expects a logged-in local browser. The downloader tries browsers from `CAPCUT_X_COOKIE_BROWSERS`, or `chrome,safari,firefox,edge` by default. -- Reliable X/Twitter discovery expects `TWITTER_BEARER_TOKEN`. -- Official TikTok sound discovery expects `TIKTOK_RESEARCH_ACCESS_TOKEN`; when it is missing, the CLI falls back to best-effort Creative Center scraping. -- TikTok music imports can still be brittle when upstream extractor behavior changes; when that happens, use `manual-url` with another supported source or import fresh URLs directly into the library. +- Rust toolchain to build the crate +- `ffmpeg` on `PATH` (or at `~/.capcut-cli/bin/ffmpeg`) +- `yt-dlp` at `~/.capcut-cli/bin/yt-dlp` (the CLI installs this itself via + `deps install`, no other runtime needed) -## Credential Safety +On macOS, `brew install ffmpeg` is the simplest way to satisfy ffmpeg. -- `TWITTER_BEARER_TOKEN` is only read from the environment at runtime; the CLI does not persist it in repo files or library manifests. -- `TIKTOK_RESEARCH_ACCESS_TOKEN` is only read from the environment at runtime; the CLI does not persist it in repo files or library manifests. -- X media import uses `yt-dlp --cookies-from-browser`, which reads your browser session from the local machine instead of asking you to paste cookie values into the repo. -- command logs redact token-like query parameters and signed URL fragments before printing to stderr. -- imported asset metadata strips token-like query parameters before saving `source_url` into `library/manifest.json`. -- `.env`, `.env.*`, and `*.local` are ignored by git so local credential files are less likely to be committed accidentally. -- copy `.env.example` to `.env` if you want a local template for the supported variables. -- you should still prefer a dedicated low-scope X API token for this tool and avoid sharing terminals/log captures from authenticated runs. -- see [SECURITY.md](SECURITY.md) for the short operational checklist we recommend before using real API tokens. +## Batch: three finished clips -## Commands +`scripts/build-clips-from-urls.sh` takes one supplied sound URL plus three +supplied clip URLs and produces a self-contained `clips/` folder: -### `deps` +- `clip_1.mp4`, `clip_2.mp4`, `clip_3.mp4` — finished vertical MP4s +- `source_sound.` — the imported audio used by all three +- `source_1.`, `source_2.`, `source_3.` — the imported clips +- `manifest.json` — provenance (the supplied URLs and compose settings) -Manage runtime dependencies. +Local invocation: ```bash -cargo run -- deps check -cargo run -- deps install +SOUND_URL="https://..." \ +CLIP_URLS="https://url1 https://url2 https://url3" \ + ./scripts/build-clips-from-urls.sh ``` -`deps check` returns structured JSON describing whether `ffmpeg` and `yt-dlp` are installed. +### Path A — GitHub Actions (phone-friendly) -### `discover` +1. Open **Actions → build-clips → Run workflow** in the GitHub mobile app. +2. Leave `mode` at `urls` (the default). +3. Paste `sound_url`, `clip_url_1`, `clip_url_2`, `clip_url_3`. Tweak + `duration` and `resolution` if desired. +4. When the run finishes, download the `clips` artifact. -Find candidate sounds and clips before importing them. +### Path B — Codespaces -```bash -# TikTok Creative Center discovery -cargo run -- discover tiktok-sounds --limit 10 --region US --window-days 7 +1. Open a Codespace on this repo (the devcontainer builds the CLI and + installs ffmpeg + yt-dlp). +2. Run: + ```bash + SOUND_URL="..." CLIP_URLS="... ... ..." make clips + ``` +3. The `clips/` folder is in the workspace; grab it from the file browser. -# X/Twitter discovery (recommended strong-yes path) -cargo run -- discover x-clips --query "ai agents" --limit 10 --min-likes 1000 +## Commands -# Lower-barrier X/Twitter options -cargo run -- discover x-clips --query "ai agents" --strategy guided -cargo run -- discover x-clips --query "ai agents" --strategy library --limit 5 -``` +### `deps` -Important behavior: +Manage runtime dependencies. -- `discover tiktok-sounds` first tries the TikTok Research API, then falls back to Creative Center JSON, song-detail crawling, and HTML scraping. -- `discover tiktok-sounds` returns ranked candidates with `music_id`, `ranking_score`, `source_path`, and an `import_url`; prefer `import_url` when you want the CLI to ingest the sound immediately. -- `discover tiktok-sounds` uses a rolling discovery window; `--window-days` defaults to `7`. -- `discover tiktok-sounds` supports explicit strategies: `auto`, `research`, `creative-center`, `library`, and `manual-url`. -- `auto` chooses the lowest-friction working path in this order: `manual-url` when `--sound-url` is provided, then `research` when a token is configured, then `creative-center`, then `library`. -- `discover x-clips` supports explicit strategies: `auto`, `api`, `guided`, `library`, and `manual-url`. -- `discover x-clips` returns ranked clip candidates with `import_url`, engagement metrics, and `ranking_score` when the API strategy succeeds. -- `auto` chooses the lowest-friction working path in this order: `manual-url` when `--clip-url` is provided, then `api` when `TWITTER_BEARER_TOKEN` is configured, then `guided`, then `library`. -- `guided` returns browser search URLs and an import hint instead of live API results; it is useful when auth is not configured, but it is not the recommended strong-yes path. -- `library` reuses previously imported clip assets for the fastest fully local workflow. +```bash +cargo run --release -- deps check +cargo run --release -- deps install +``` + +`deps check` returns structured JSON describing whether `ffmpeg` and `yt-dlp` +are installed. `deps install` downloads the standalone `yt-dlp` binary from +the upstream GitHub release for macOS and Linux. ### `library` @@ -132,31 +130,35 @@ Manage local media assets stored under `library/`. ```bash # Import from a supported URL -cargo run -- library import "https://www.tiktok.com/embed/v2/..." --type sound --tags trending,tiktok -cargo run -- library import "https://x.com/user/status/123" --type clip --tags viral,demo -cargo run -- library import "https://www.youtube.com/watch?v=..." --type clip --tags fresh,youtube -cargo run -- library import "https://www.youtube.com/watch?v=..." --type sound --tags fresh,youtube +./target/release/capcut-cli library import \ + "https://www.tiktok.com/music/..." --type sound --tags trending,tiktok +./target/release/capcut-cli library import \ + "https://x.com/user/status/123" --type clip --tags source # Inspect the library -cargo run -- library list -cargo run -- library list --type sound -cargo run -- library show snd_bf6bbb0a +./target/release/capcut-cli library list +./target/release/capcut-cli library list --type sound +./target/release/capcut-cli library show snd_demo001 # Remove an asset -cargo run -- library delete snd_bf6bbb0a +./target/release/capcut-cli library delete snd_demo001 ``` Import behavior: -- `--type` is optional; TikTok `/music/` URLs are auto-detected as sounds and everything else defaults to clips. -- for TikTok sound imports discovered via Creative Center or Research API enrichment, prefer the returned `import_url` -- sounds are downloaded with `yt-dlp`, converted to MP3, and stored under `library/sounds/assets//` -- clips are downloaded with `yt-dlp` and stored under `library/clips//` +- `--type` is optional; TikTok `/music/` URLs are auto-detected as sounds, + everything else defaults to clip +- sounds are downloaded with `yt-dlp`, converted to MP3, and stored under + `library/sounds/assets//` +- clips are downloaded with `yt-dlp` and stored under + `library/clips//` - imported assets are indexed in `library/manifest.json` -- X/Twitter clip imports use authenticated browser cookies by default and emit distinct structured errors for missing auth, suspended tweets, missing video media, unavailable video, and rate limiting -- manual URL import is the most reliable way to guarantee fresh content when platform discovery or extractors are temporarily degraded +- X/Twitter imports use authenticated browser cookies via + `yt-dlp --cookies-from-browser` and emit distinct structured error codes + for missing auth, suspended tweets, missing video media, unavailable video, + and rate limiting -Supported source platforms currently detected by the downloader: +Supported source platforms detected by the downloader: - TikTok - X/Twitter @@ -168,9 +170,9 @@ Supported source platforms currently detected by the downloader: Render one final MP4 from one sound plus one or more clips. ```bash -cargo run -- compose \ - --sound snd_bf6bbb0a \ - --clip clp_31cd891e \ +./target/release/capcut-cli compose \ + --sound snd_demo001 \ + --clip clp_demo001 \ --duration 20 \ --resolution 1080x1920 \ --loudness viral @@ -187,7 +189,7 @@ Options: Built-in loudness presets: -- `viral`: `-8 LUFS` default +- `viral`: `-8 LUFS` - `social`: `-10 LUFS` - `podcast`: `-14 LUFS` - `broadcast`: `-23 LUFS` @@ -200,58 +202,46 @@ Compose pipeline: 4. scale and center-crop clips to the requested resolution 5. concatenate clips and mux AAC audio into the final MP4 -If `--output` is omitted, the CLI writes to `library/output/comp_/final.mp4`. - -### `autopilot` - -Run one agent-facing command that: -1. discovers TikTok sounds -2. discovers X clips for your topic -3. imports the first successful sound + clip candidates -4. composes the final MP4 - -This command works best when: -- `TIKTOK_RESEARCH_ACCESS_TOKEN` is set for official TikTok sound discovery -- `TWITTER_BEARER_TOKEN` is set for official X clip discovery -- a supported local browser is logged into X for media import - -Sound strategy options for agents: -- `auto`: choose the best available option from repo/runtime context -- `research`: official TikTok Research API path -- `creative-center`: public scrape with no token, but more brittle -- `library`: reuse local sound assets for the lowest barrier to entry -- `manual-url`: use a caller-provided sound URL directly - -Clip strategy options for agents: -- `auto`: choose the best available option from repo/runtime context -- `api`: official X API path when `TWITTER_BEARER_TOKEN` is configured -- `guided`: browser-search fallback that returns search URLs and an import hint -- `library`: reuse local clip assets for the lowest barrier to entry -- `manual-url`: use a caller-provided X clip URL directly - -Practical agent guidance: -- use `auto` when credentials are configured and freshness matters more than determinism -- use `library` when you need the fastest guaranteed local success -- use `manual-url` when you already have a fresh source URL and want the most predictable non-library path -- if TikTok or X discovery is degraded, importing fresh URLs from another supported platform such as YouTube is still a valid path to a brand-new output +If `--output` is omitted, the CLI writes to +`library/output/comp_/final.mp4`. + +## Optional: API-gated discovery (experimental) + +> ⚠️ These commands depend on external APIs that are hard to obtain or paywalled, +> and public fallbacks are brittle. Use them as a convenience on top of the +> manual-URL spine, not as the primary path. + +### Token availability at a glance + +- **TikTok Research API** (`TIKTOK_RESEARCH_ACCESS_TOKEN`): restricted to + academic researchers at non-profit institutions; commercial applicants are + routinely rejected and approval takes weeks. Unauthenticated Creative Center + scraping exists as a fallback but is frequently degraded upstream. +- **X/Twitter API** (`TWITTER_BEARER_TOKEN`): the recent-search endpoint this + CLI uses is not on the Free tier. Minimum is Basic at $200/month. + +If you have the tokens: ```bash -cargo run -- autopilot \ - --query "ai agents" \ - --region US \ - --window-days 7 \ - --sound-strategy auto \ - --clip-strategy auto \ - --sound-limit 5 \ - --clip-limit 5 \ - --min-likes 1000 \ - --duration 15 \ - --resolution 1080x1920 +export TIKTOK_RESEARCH_ACCESS_TOKEN=... +export TWITTER_BEARER_TOKEN=... + +./target/release/capcut-cli discover tiktok-sounds --limit 5 --region US --window-days 7 +./target/release/capcut-cli discover x-clips --query "ai agents" --limit 5 --min-likes 1000 + +# Or end-to-end: +./target/release/capcut-cli autopilot --query "ai agents" --duration 15 ``` +The discovery-mode batch path is also available in the Actions workflow by +setting `mode: discovery` and adding both tokens as repo secrets. Downloads +from Actions may be rate-limited or blocked on data-center IPs even when +discovery succeeds — this is why the manual-URL path is the recommended one. + ## Agent-first output contract -Every successful command prints a structured JSON envelope to stdout. Progress logs go to stderr. +Every successful command prints a structured JSON envelope to stdout. Progress +logs go to stderr. Example: @@ -280,6 +270,26 @@ Behavior guarantees: - all imported asset paths and compose output paths are emitted as absolute paths - structured error codes distinguish setup failures from media/data failures on X/Twitter +## Credential safety + +- `TWITTER_BEARER_TOKEN` and `TIKTOK_RESEARCH_ACCESS_TOKEN` are only read from + the environment at runtime; the CLI does not persist them in repo files or + library manifests. +- X media import uses `yt-dlp --cookies-from-browser`, which reads your local + browser session instead of asking you to paste cookie values into the repo. +- command logs redact token-like query parameters and signed URL fragments + before printing to stderr. +- imported asset metadata strips token-like query parameters before saving + `source_url` into `library/manifest.json`. +- `.env`, `.env.*`, and `*.local` are ignored by git so local credential files + are less likely to be committed accidentally. +- copy `.env.example` to `.env` if you want a local template for the supported + variables. +- prefer a dedicated low-scope X API token for this tool and avoid sharing + terminals or log captures from authenticated runs. +- see [SECURITY.md](SECURITY.md) for the operational checklist we recommend + before using real API tokens. + ## Repository layout ```text @@ -288,8 +298,8 @@ src/ config.rs # paths, version, loudness presets deps.rs # ffmpeg checks and yt-dlp installation discover/ - tiktok.rs # TikTok Creative Center discovery - twitter.rs # X/Twitter API or guided discovery + tiktok.rs # TikTok discovery (API-gated, optional) + twitter.rs # X/Twitter discovery (API-gated, optional) library.rs # import/list/show/delete asset workflow media/ compose.rs # end-to-end composition pipeline @@ -299,31 +309,37 @@ src/ output.rs # JSON envelope helpers library/ manifest.json # imported asset index used by the CLI - sounds/ # sound assets and committed sound notes + sounds/ # sound assets and committed seed media clips/ # imported clip assets output/ # composed videos +scripts/ + build-clips-from-urls.sh # primary: compose 3 clips from supplied URLs + build-clips.sh # optional: discovery-driven batch +tests/ + e2e_url_to_clip.rs # end-to-end import → compose smoke test ``` ## Committed demo assets -This repository currently includes real local demo assets in `library/manifest.json`, including: - -- `snd_bf6bbb0a` -- `clp_31cd891e` +`library/manifest.json` references two small committed fixtures so `compose` +works immediately on a freshly cloned repo: -That means you can run `compose` immediately on a freshly cloned repo once `ffmpeg` is available. +- `snd_demo001` — 2-second 440 Hz sine tone at `library/sounds/assets/snd_demo001/audio.mp3` +- `clp_demo001` — 3-second solid-color vertical MP4 at `library/clips/clp_demo001/video.mp4` -There is also a smaller committed seed audio sample at `library/sounds/samples/seed-preview-loop.wav` for library documentation and inspection. +These are synthetic, not "trending" — they exist so the compose pipeline is +inspectable without network access. For real trending material, use the +manual-URL import flow. ## Testing -Run the Rust test suite with: +Run the full Rust test suite with: ```bash -cargo test +cargo test --all-targets ``` -At the time of this update, the suite contains coverage for: +Coverage currently includes: - X clip scoring and guided-fallback labeling - TikTok `import_url` normalization @@ -332,32 +348,10 @@ At the time of this update, the suite contains coverage for: - loudness preset resolution - numeric loudness parsing - duration parsing in the ffmpeg helpers -- a compose smoke test over existing library assets - -## Live Acceptance Flow - -The intended strong-yes flow is: - -1. `cargo run -- deps check` -2. `cargo run -- discover tiktok-sounds --limit 5 --region US --window-days 7` -3. `cargo run -- discover x-clips --query "" --limit 5 --min-likes 1000` -4. import the TikTok sound using the returned `import_url` -5. import the X clip using the returned `import_url` -6. `cargo run -- compose --sound --clip --duration 10 --resolution 1080x1920` - -Or run the same flow in one command: - -- `cargo run -- autopilot --query "" --region US --window-days 7 --sound-strategy auto --clip-strategy auto --duration 15` - -Expected environment for that path: - -- `TWITTER_BEARER_TOKEN` is set -- at least one supported logged-in browser is available locally for X media import -- `ffmpeg` is installed - -## What changed from the old Python version +- a compose smoke test over the committed demo assets +- **an end-to-end integration test (`tests/e2e_url_to_clip.rs`) that exercises + the full import-from-URL → compose spine via a yt-dlp shim, so the honest + minimum viable truth is verifiable in CI** -- the production CLI is now Rust, built with `clap` -- runtime behavior lives in `src/`, not `py/` -- dependency bootstrapping is handled in Rust -- the README no longer assumes virtualenvs, `pip`, or Click-based commands +The `test` GitHub Actions workflow runs `cargo test --all-targets` on every +push. diff --git a/library/clips/clp_demo001/video.mp4 b/library/clips/clp_demo001/video.mp4 new file mode 100644 index 0000000..567b796 Binary files /dev/null and b/library/clips/clp_demo001/video.mp4 differ diff --git a/library/manifest.json b/library/manifest.json new file mode 100644 index 0000000..6091d6c --- /dev/null +++ b/library/manifest.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "assets": [ + { + "id": "snd_demo001", + "type": "sound", + "title": "Demo sine tone", + "source_url": "local_seed://snd_demo001", + "source_platform": "local_seed", + "downloaded_at": "2026-04-18T00:00:00Z", + "duration_seconds": 2.0, + "file_path": "library/sounds/assets/snd_demo001/audio.mp3", + "file_size_bytes": 33062, + "format": "mp3", + "tags": ["demo", "seed"] + }, + { + "id": "clp_demo001", + "type": "clip", + "title": "Demo color bars", + "source_url": "local_seed://clp_demo001", + "source_platform": "local_seed", + "downloaded_at": "2026-04-18T00:00:00Z", + "duration_seconds": 3.0, + "file_path": "library/clips/clp_demo001/video.mp4", + "file_size_bytes": 33456, + "format": "mp4", + "tags": ["demo", "seed"] + } + ] +} diff --git a/library/sounds/README.md b/library/sounds/README.md index a88ee63..75e0257 100644 --- a/library/sounds/README.md +++ b/library/sounds/README.md @@ -1,32 +1,33 @@ # Sound library -This directory holds committed TikTok sound metadata and selected audio samples for the first deliverable. - -## Goals - -- keep a growing library of popular sounds in-repo -- store normalized metadata for every sound -- keep at least a small committed sample set so the pipeline is inspectable -- make it easy for an agent to add more sounds over time +This directory holds imported sound assets and a small committed sample set +for inspection. ## Structure -- `manifest.json` — top-level library index -- `seed/` — manually curated or initially imported sounds -- `samples/` — committed audio files that can be previewed and shared for feedback +- `assets/` — imported sound assets, one directory per asset id + (e.g. `assets/snd_demo001/audio.mp3`). New imports land here. +- `samples/` — standalone committed audio samples kept for reference +- `manifest.json` — legacy seed manifest from the first deliverable; the + authoritative library index now lives at the repo-root + `library/manifest.json` -## Metadata expectations +## Where metadata actually lives -Each sound entry should track: +Every imported sound is indexed in the top-level `library/manifest.json` +with: -- stable local id +- stable local id (e.g. `snd_demo001`) +- asset type (`sound`) +- title +- source URL (redacted of token-like query parameters) - source platform -- source URL or source identifier -- title or inferred label -- creator/uploader when known -- duration -- local committed path if present -- acquisition method -- rights/provenance note +- download timestamp +- duration in seconds +- absolute file path on disk +- file size in bytes +- file format - tags -- status + +A per-asset `meta.json` is also written alongside the audio file in +`assets//meta.json` during import. diff --git a/library/sounds/assets/snd_demo001/audio.mp3 b/library/sounds/assets/snd_demo001/audio.mp3 new file mode 100644 index 0000000..f2518d4 Binary files /dev/null and b/library/sounds/assets/snd_demo001/audio.mp3 differ diff --git a/scripts/build-clips-from-urls.sh b/scripts/build-clips-from-urls.sh new file mode 100755 index 0000000..624b471 --- /dev/null +++ b/scripts/build-clips-from-urls.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Compose three finished MP4s from one supplied trending sound URL plus three +# supplied source clip URLs. No discovery APIs; the caller brings the links. +# +# Usage: +# SOUND_URL=https://... CLIP_URLS="https://a https://b https://c" \ +# ./scripts/build-clips-from-urls.sh +# +# Tunables (with defaults): +# DURATION="15" RESOLUTION="1080x1920" CLIPS_DIR="./clips" + +set -euo pipefail + +: "${SOUND_URL:?SOUND_URL is required}" +: "${CLIP_URLS:?CLIP_URLS is required (space-separated list of three URLs)}" + +DURATION="${DURATION:-15}" +RESOLUTION="${RESOLUTION:-1080x1920}" +CLIPS_DIR="${CLIPS_DIR:-./clips}" +BIN="${BIN:-./target/release/capcut-cli}" + +log() { printf '[build-clips] %s\n' "$*" >&2; } +die() { log "ERROR: $*"; exit 1; } + +command -v jq >/dev/null || die "jq is required" +[[ -x "$BIN" ]] || die "capcut-cli binary not found at $BIN (run 'cargo build --release')" + +read -r -a CLIP_ARR <<< "$CLIP_URLS" +[[ ${#CLIP_ARR[@]} -eq 3 ]] || die "CLIP_URLS must contain exactly three URLs (got ${#CLIP_ARR[@]})" + +"$BIN" deps check >/dev/null || die "deps check failed" + +# ── Import the supplied sound ──────────────────────────────────────── +log "importing sound: $SOUND_URL" +SOUND_JSON=$("$BIN" library import "$SOUND_URL" --type sound --tags "manual,supplied" || true) +echo "$SOUND_JSON" | jq -e '.status == "ok"' >/dev/null 2>&1 \ + || { echo "$SOUND_JSON" >&2; die "sound import failed"; } +SOUND_ID=$(echo "$SOUND_JSON" | jq -r '.data.id') +SOUND_PATH=$(echo "$SOUND_JSON" | jq -r '.data.file_path') +SOUND_FMT=$(echo "$SOUND_JSON" | jq -r '.data.format') + +# ── Import each supplied clip ──────────────────────────────────────── +CLIP_IDS=(); CLIP_PATHS=(); CLIP_FMTS=() +for url in "${CLIP_ARR[@]}"; do + log "importing clip: $url" + OUT=$("$BIN" library import "$url" --type clip --tags "manual,supplied" || true) + echo "$OUT" | jq -e '.status == "ok"' >/dev/null 2>&1 \ + || { echo "$OUT" >&2; die "clip import failed for $url"; } + CLIP_IDS+=("$(echo "$OUT" | jq -r '.data.id')") + CLIP_PATHS+=("$(echo "$OUT" | jq -r '.data.file_path')") + CLIP_FMTS+=("$(echo "$OUT" | jq -r '.data.format')") +done + +# ── Compose three finished clips ───────────────────────────────────── +rm -rf "$CLIPS_DIR" +mkdir -p "$CLIPS_DIR" + +for i in 0 1 2; do + n=$((i + 1)) + out="$CLIPS_DIR/clip_${n}.mp4" + log "compose clip_${n} (sound=$SOUND_ID, clip=${CLIP_IDS[$i]})" + "$BIN" compose \ + --sound "$SOUND_ID" \ + --clip "${CLIP_IDS[$i]}" \ + --duration "$DURATION" \ + --resolution "$RESOLUTION" \ + --output "$out" >/dev/null + [[ -f "$out" ]] || die "compose did not produce $out" +done + +# ── Stage real source references alongside the finished clips ──────── +cp "$SOUND_PATH" "$CLIPS_DIR/source_sound.${SOUND_FMT}" +for i in 0 1 2; do + n=$((i + 1)) + cp "${CLIP_PATHS[$i]}" "$CLIPS_DIR/source_${n}.${CLIP_FMTS[$i]}" +done + +# ── Provenance manifest ────────────────────────────────────────────── +jq -n \ + --arg sound_url "$SOUND_URL" \ + --arg duration "$DURATION" --arg resolution "$RESOLUTION" \ + --argjson clip_urls "$(printf '%s\n' "${CLIP_ARR[@]}" | jq -R . | jq -s .)" \ + '{source:"supplied-urls", sound_url:$sound_url, clip_urls:$clip_urls, + duration_seconds:($duration|tonumber), resolution:$resolution}' \ + > "$CLIPS_DIR/manifest.json" + +log "done — contents of $CLIPS_DIR:" +ls -la "$CLIPS_DIR" >&2 diff --git a/scripts/build-clips.sh b/scripts/build-clips.sh new file mode 100755 index 0000000..cc7ccc1 --- /dev/null +++ b/scripts/build-clips.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Discover one trending TikTok sound plus three ranked X clips, compose three +# finished MP4s, and stage them alongside the real source assets under ./clips. +# +# Required environment for real discovery: +# TIKTOK_RESEARCH_ACCESS_TOKEN — TikTok Research API token +# TWITTER_BEARER_TOKEN — X/Twitter API bearer token +# +# Tunables (with defaults): +# QUERY="ai agents" REGION="US" WINDOW_DAYS="7" +# DURATION="15" RESOLUTION="1080x1920" +# MIN_LIKES="1000" SOUND_LIMIT="5" CLIP_LIMIT="10" +# CLIPS_DIR="./clips" + +set -euo pipefail + +QUERY="${QUERY:-ai agents}" +REGION="${REGION:-US}" +WINDOW_DAYS="${WINDOW_DAYS:-7}" +DURATION="${DURATION:-15}" +RESOLUTION="${RESOLUTION:-1080x1920}" +MIN_LIKES="${MIN_LIKES:-1000}" +SOUND_LIMIT="${SOUND_LIMIT:-5}" +CLIP_LIMIT="${CLIP_LIMIT:-10}" +CLIPS_DIR="${CLIPS_DIR:-./clips}" +BIN="${BIN:-./target/release/capcut-cli}" + +log() { printf '[build-clips] %s\n' "$*" >&2; } +die() { log "ERROR: $*"; exit 1; } + +command -v jq >/dev/null || die "jq is required" +[[ -x "$BIN" ]] || die "capcut-cli binary not found at $BIN (run 'cargo build --release')" + +"$BIN" deps check >/dev/null || die "deps check failed" + +# ── 1. Discover trending TikTok sound ──────────────────────────────── +log "discover tiktok-sounds (region=$REGION, window=${WINDOW_DAYS}d, limit=$SOUND_LIMIT)" +SOUND_JSON=$("$BIN" discover tiktok-sounds \ + --limit "$SOUND_LIMIT" --region "$REGION" --window-days "$WINDOW_DAYS" || true) +echo "$SOUND_JSON" | jq -e '.status == "ok"' >/dev/null 2>&1 \ + || { echo "$SOUND_JSON" >&2; die "tiktok-sounds discovery failed (is TIKTOK_RESEARCH_ACCESS_TOKEN set?)"; } + +mapfile -t SOUND_URLS < <(echo "$SOUND_JSON" | jq -r '.data.sounds[].import_url // empty') +[[ ${#SOUND_URLS[@]} -gt 0 ]] || die "no sound candidates returned" + +# ── 2. Discover trending X clips ───────────────────────────────────── +log "discover x-clips (query='$QUERY', min_likes=$MIN_LIKES, limit=$CLIP_LIMIT)" +CLIPS_JSON=$("$BIN" discover x-clips \ + --query "$QUERY" --limit "$CLIP_LIMIT" --min-likes "$MIN_LIKES" || true) +echo "$CLIPS_JSON" | jq -e '.status == "ok"' >/dev/null 2>&1 \ + || { echo "$CLIPS_JSON" >&2; die "x-clips discovery failed (is TWITTER_BEARER_TOKEN set?)"; } + +mapfile -t CLIP_URLS < <(echo "$CLIPS_JSON" | jq -r '.data.clips[].import_url // empty') +[[ ${#CLIP_URLS[@]} -ge 3 ]] || die "need at least 3 clip candidates; got ${#CLIP_URLS[@]}" + +# ── 3. Import the first sound that succeeds ────────────────────────── +SOUND_ID=""; SOUND_PATH=""; SOUND_FMT="" +for url in "${SOUND_URLS[@]}"; do + log "importing sound: $url" + if OUT=$("$BIN" library import "$url" --type sound --tags "trending,auto" 2>/dev/null); then + if echo "$OUT" | jq -e '.status == "ok"' >/dev/null; then + SOUND_ID=$(echo "$OUT" | jq -r '.data.id') + SOUND_PATH=$(echo "$OUT" | jq -r '.data.file_path') + SOUND_FMT=$(echo "$OUT" | jq -r '.data.format') + break + fi + fi + log " skip (import failed)" +done +[[ -n "$SOUND_ID" ]] || die "no sound candidate imported successfully" +log "sound imported: id=$SOUND_ID path=$SOUND_PATH" + +# ── 4. Import clips until we have three successes ──────────────────── +CLIP_IDS=(); CLIP_PATHS=(); CLIP_FMTS=() +for url in "${CLIP_URLS[@]}"; do + [[ ${#CLIP_IDS[@]} -ge 3 ]] && break + log "importing clip: $url" + if OUT=$("$BIN" library import "$url" --type clip --tags "trending,auto" 2>/dev/null); then + if echo "$OUT" | jq -e '.status == "ok"' >/dev/null; then + CLIP_IDS+=("$(echo "$OUT" | jq -r '.data.id')") + CLIP_PATHS+=("$(echo "$OUT" | jq -r '.data.file_path')") + CLIP_FMTS+=("$(echo "$OUT" | jq -r '.data.format')") + continue + fi + fi + log " skip (import failed)" +done +[[ ${#CLIP_IDS[@]} -ge 3 ]] || die "fewer than 3 clips imported successfully (${#CLIP_IDS[@]})" + +# ── 5. Compose three finished clips ────────────────────────────────── +rm -rf "$CLIPS_DIR" +mkdir -p "$CLIPS_DIR" + +for i in 0 1 2; do + n=$((i + 1)) + out="$CLIPS_DIR/clip_${n}.mp4" + log "compose clip_${n} (sound=$SOUND_ID, clip=${CLIP_IDS[$i]})" + "$BIN" compose \ + --sound "$SOUND_ID" \ + --clip "${CLIP_IDS[$i]}" \ + --duration "$DURATION" \ + --resolution "$RESOLUTION" \ + --output "$out" >/dev/null + [[ -f "$out" ]] || die "compose did not produce $out" +done + +# ── 6. Stage real source references alongside the finished clips ───── +cp "$SOUND_PATH" "$CLIPS_DIR/source_sound.${SOUND_FMT}" +for i in 0 1 2; do + n=$((i + 1)) + cp "${CLIP_PATHS[$i]}" "$CLIPS_DIR/source_${n}.${CLIP_FMTS[$i]}" +done + +# ── 7. Write a small manifest pointing at the real provenance ──────── +jq -n \ + --arg query "$QUERY" --arg region "$REGION" \ + --arg duration "$DURATION" --arg resolution "$RESOLUTION" \ + --argjson sound "$(echo "$SOUND_JSON" | jq '.data.sounds[0]')" \ + --argjson clips "$(echo "$CLIPS_JSON" | jq "[.data.clips[0:${#CLIP_IDS[@]}][]]")" \ + '{query:$query, region:$region, duration_seconds:($duration|tonumber), + resolution:$resolution, sound:$sound, clips:$clips}' \ + > "$CLIPS_DIR/manifest.json" + +log "done — contents of $CLIPS_DIR:" +ls -la "$CLIPS_DIR" >&2 diff --git a/src/config.rs b/src/config.rs index c0065b4..163e08a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,6 +35,9 @@ pub fn bin_dir() -> PathBuf { capcut_home().join("bin") } pub fn ytdlp_path() -> PathBuf { + if let Ok(p) = std::env::var("CAPCUT_YTDLP_PATH") { + return PathBuf::from(p); + } bin_dir().join("yt-dlp") } diff --git a/src/media/downloader.rs b/src/media/downloader.rs index dfcf501..9e36f49 100644 --- a/src/media/downloader.rs +++ b/src/media/downloader.rs @@ -522,7 +522,7 @@ mod tests { } #[cfg(test)] -mod tests { +mod tests_url_detection { use super::*; // ── detect_platform ──────────────────────────────────────────── diff --git a/tests/e2e_url_to_clip.rs b/tests/e2e_url_to_clip.rs new file mode 100644 index 0000000..fd286a3 --- /dev/null +++ b/tests/e2e_url_to_clip.rs @@ -0,0 +1,172 @@ +//! End-to-end smoke test for the manual-URL spine: library import → compose. +//! +//! Proves that given an external URL, the CLI downloads (via a yt-dlp shim for +//! test isolation), registers the asset, and composes a real MP4. No network +//! required; the shim copies committed fixture media to yt-dlp's expected +//! output template, so the rest of the pipeline (metadata extraction via +//! ffprobe, loudness normalization via ffmpeg, compose) runs against real +//! bytes. +//! +//! Exercised because the product's honest minimum viable truth is +//! "fresh input in, finished clip out." + +use std::path::PathBuf; +use std::process::Command; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn write_ytdlp_shim(workdir: &std::path::Path) -> PathBuf { + let shim = workdir.join("ytdlp-shim.sh"); + let fixture_audio = repo_root().join("library/sounds/assets/snd_demo001/audio.mp3"); + let fixture_video = repo_root().join("library/clips/clp_demo001/video.mp4"); + + // Shim behavior: + // --dump-json --no-download → emit a minimal JSON metadata blob + // -o