From c458700e0b46a5c622d2205e6a692e577b0d6bb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 17:16:28 +0000 Subject: [PATCH 1/4] Add Actions + Codespaces paths for trending-driven clip batches Two phone-friendly ways to run real discovery and produce three composed MP4s alongside their source audio and clip assets: - .github/workflows/build-clips.yml: manual-dispatch workflow that checks for API tokens, installs ffmpeg/yt-dlp, builds the CLI, runs discovery and compose via scripts/build-clips.sh, and uploads a clips/ artifact. - .devcontainer/: Rust+Python devcontainer with a post-create step that wires yt-dlp into ~/.capcut-cli/bin and builds the release binary; pairs with `make clips` for one-command runs inside Codespaces. - scripts/build-clips.sh: discovers one trending sound + three ranked X clips, imports each, composes clip_{1,2,3}.mp4, and stages the real source references plus a manifest.json under ./clips. - README: documents both paths and the required repo/Codespace secrets. --- .devcontainer/devcontainer.json | 22 ++++++ .devcontainer/post-create.sh | 15 ++++ .github/workflows/build-clips.yml | 76 ++++++++++++++++++ Makefile | 18 +++++ README.md | 41 ++++++++++ scripts/build-clips.sh | 125 ++++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100755 .devcontainer/post-create.sh create mode 100644 .github/workflows/build-clips.yml create mode 100644 Makefile create mode 100755 scripts/build-clips.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..2ed8040 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,22 @@ +{ + "name": "capcut-cli", + "image": "mcr.microsoft.com/devcontainers/rust:1-bookworm", + "features": { + "ghcr.io/devcontainers/features/python:1": { + "version": "3.12" + } + }, + "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..7b3fb07 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# One-shot Codespace bootstrap: install ffmpeg + yt-dlp, build the CLI. +set -euo pipefail + +sudo apt-get update +sudo apt-get install -y ffmpeg jq + +python3 -m pip install --quiet --upgrade yt-dlp +mkdir -p "$HOME/.capcut-cli/bin" +ln -sf "$(command -v yt-dlp)" "$HOME/.capcut-cli/bin/yt-dlp" + +cargo build --release + +./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/.github/workflows/build-clips.yml b/.github/workflows/build-clips.yml new file mode 100644 index 0000000..8de78c3 --- /dev/null +++ b/.github/workflows/build-clips.yml @@ -0,0 +1,76 @@ +name: build-clips + +on: + workflow_dispatch: + inputs: + query: + description: "Topic used for X/Twitter clip discovery" + default: "ai agents" + required: true + region: + description: "Region code for TikTok sound discovery" + default: "US" + window_days: + description: "Rolling window (days) for TikTok sound discovery" + default: "7" + duration: + description: "Output duration per finished clip (seconds)" + default: "15" + resolution: + description: "Output resolution (WxH)" + default: "1080x1920" + min_likes: + description: "Minimum likes threshold for X clip discovery" + default: "1000" + +jobs: + build: + runs-on: ubuntu-latest + env: + TIKTOK_RESEARCH_ACCESS_TOKEN: ${{ secrets.TIKTOK_RESEARCH_ACCESS_TOKEN }} + TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN }} + QUERY: ${{ inputs.query }} + REGION: ${{ inputs.region }} + WINDOW_DAYS: ${{ inputs.window_days }} + DURATION: ${{ inputs.duration }} + RESOLUTION: ${{ inputs.resolution }} + MIN_LIKES: ${{ inputs.min_likes }} + steps: + - uses: actions/checkout@v4 + + - name: Fail fast if API tokens are missing + run: | + missing="" + [[ -z "${TIKTOK_RESEARCH_ACCESS_TOKEN}" ]] && missing+=" TIKTOK_RESEARCH_ACCESS_TOKEN" + [[ -z "${TWITTER_BEARER_TOKEN}" ]] && missing+=" TWITTER_BEARER_TOKEN" + if [[ -n "$missing" ]]; then + echo "Missing required repo secrets:$missing" >&2 + echo "Add them under Settings → Secrets and variables → Actions." >&2 + exit 1 + fi + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg jq + python3 -m pip install --quiet --upgrade yt-dlp + mkdir -p "$HOME/.capcut-cli/bin" + ln -sf "$(command -v yt-dlp)" "$HOME/.capcut-cli/bin/yt-dlp" + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Build capcut-cli + run: cargo build --release + + - name: Discover, import, compose + 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/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..fb7b310 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,47 @@ Expected environment for that path: - at least one supported logged-in browser is available locally for X media import - `ffmpeg` is installed +## Batch: three finished clips from trending discovery + +Two phone-friendly paths that run real discovery and produce three composed +MP4s plus their real source assets under `clips/`. + +Both paths require: + +- `TIKTOK_RESEARCH_ACCESS_TOKEN` — for trending TikTok sound discovery +- `TWITTER_BEARER_TOKEN` — for ranked X clip discovery + +### Path A — GitHub Actions (one tap from the mobile app) + +1. Add the two tokens under **Settings → Secrets and variables → Actions**. +2. From the GitHub mobile app: **Actions → build-clips → Run workflow**. + Optional inputs: `query`, `region`, `window_days`, `duration`, `resolution`, + `min_likes`. +3. When the run finishes, download the `clips` artifact from the run page. + +Caveat: discovery APIs work from Actions runners, but the `yt-dlp` download +step can be rate-limited or blocked on data-center IPs, and X media import +has no logged-in browser here. Path B is the more reliable lane. + +### Path B — Codespaces (also mobile-viable) + +1. Add the two tokens as **Codespace secrets** on this repo. +2. Open a Codespace from the mobile app (the devcontainer builds the CLI). +3. In the terminal: `make clips`. +4. The finished clips and source references land in `./clips/`; download the + folder from the Codespace file browser. + +### What lands in `clips/` + +- `clip_1.mp4`, `clip_2.mp4`, `clip_3.mp4` — finished vertical MP4s +- `source_sound.mp3` — the real trending audio used by all three +- `source_1.*`, `source_2.*`, `source_3.*` — the real source clips +- `manifest.json` — provenance (discovery response snippets for sound + clips) + +You can also run the pipeline directly: `./scripts/build-clips.sh`. It accepts +the same knobs via env vars (`QUERY`, `REGION`, `WINDOW_DAYS`, `DURATION`, +`RESOLUTION`, `MIN_LIKES`, `CLIPS_DIR`). + ## What changed from the old Python version - the production CLI is now Rust, built with `clap` 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 From 66c7c4fe4d6b7b27bfad9e1d9ee740b94945dcb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 17:21:30 +0000 Subject: [PATCH 2/4] Drop Python from bootstrap; use 'capcut-cli deps install' for yt-dlp The CLI is Rust-only; yt-dlp itself is distributed as a standalone Linux binary by the upstream project, and 'deps install' already downloads it. Using that path keeps Python off the install surface for both the Action and the devcontainer. --- .devcontainer/devcontainer.json | 5 ----- .devcontainer/post-create.sh | 10 ++++------ .github/workflows/build-clips.yml | 8 ++++---- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2ed8040..075d575 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,11 +1,6 @@ { "name": "capcut-cli", "image": "mcr.microsoft.com/devcontainers/rust:1-bookworm", - "features": { - "ghcr.io/devcontainers/features/python:1": { - "version": "3.12" - } - }, "postCreateCommand": "bash .devcontainer/post-create.sh", "remoteEnv": { "TIKTOK_RESEARCH_ACCESS_TOKEN": "${localEnv:TIKTOK_RESEARCH_ACCESS_TOKEN}", diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 7b3fb07..085bdaf 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -1,15 +1,13 @@ #!/usr/bin/env bash -# One-shot Codespace bootstrap: install ffmpeg + yt-dlp, build the CLI. +# One-shot Codespace bootstrap: install ffmpeg, build the CLI, let the CLI +# install its own yt-dlp binary. No Python toolchain required. set -euo pipefail sudo apt-get update sudo apt-get install -y ffmpeg jq -python3 -m pip install --quiet --upgrade yt-dlp -mkdir -p "$HOME/.capcut-cli/bin" -ln -sf "$(command -v yt-dlp)" "$HOME/.capcut-cli/bin/yt-dlp" - 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/.github/workflows/build-clips.yml b/.github/workflows/build-clips.yml index 8de78c3..d8fe775 100644 --- a/.github/workflows/build-clips.yml +++ b/.github/workflows/build-clips.yml @@ -49,13 +49,10 @@ jobs: exit 1 fi - - name: Install system dependencies + - name: Install ffmpeg and jq run: | sudo apt-get update sudo apt-get install -y ffmpeg jq - python3 -m pip install --quiet --upgrade yt-dlp - mkdir -p "$HOME/.capcut-cli/bin" - ln -sf "$(command -v yt-dlp)" "$HOME/.capcut-cli/bin/yt-dlp" - uses: dtolnay/rust-toolchain@stable @@ -64,6 +61,9 @@ jobs: - 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: Discover, import, compose run: ./scripts/build-clips.sh From c1e94c9c95dbab6c2f17449cddff0ed025d77448 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 17:58:11 +0000 Subject: [PATCH 3/4] Scope down to manual-URL spine; make compose verifiable end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product decision: discovery depends on gated APIs (TikTok Research access, X paid-tier search) whose availability we can't guarantee. Treat "fresh input in, finished clip out" as the honest minimum viable truth and make the compose pipeline the solid thing the tool does. - README rewritten around the manual-URL flow. The Quick Start, batch guide, and command reference all center on library import + compose. The discover/autopilot commands are moved into an "Optional: API-gated discovery (experimental)" section with explicit caveats about token gating. Stale claims removed. Python references stripped. - scripts/build-clips-from-urls.sh: primary batch path. Takes one supplied sound URL plus three supplied clip URLs and produces a self-contained clips/ folder with finished MP4s, source references, and a provenance manifest. - .github/workflows/build-clips.yml: gains a mode selector; urls mode is the default, discovery mode is opt-in behind both repo secrets. - tests/e2e_url_to_clip.rs: end-to-end smoke test that exercises the full library import → compose pipeline via a yt-dlp shim, so the honest minimum viable truth is verifiable without network access. - .github/workflows/test.yml: runs cargo test --all-targets on every push so the spine stays provable in CI. - library/manifest.json plus small synthetic demo fixtures under library/sounds/assets/snd_demo001 and library/clips/clp_demo001 so compose works immediately on a fresh clone and the compose smoke test has real bytes to exercise. - src/config.rs: CAPCUT_YTDLP_PATH env override so tests can inject a yt-dlp shim. Production path unchanged. - src/media/downloader.rs: merge two duplicate #[cfg(test)] mod tests blocks that were preventing cargo test from compiling at all. - .gitignore: trimmed Python section, whitelisted the committed demo fixtures, anchored the batch-output ignore to the repo root. --- .github/workflows/build-clips.yml | 85 +++- .github/workflows/test.yml | 25 ++ .gitignore | 29 +- README.md | 423 +++++++++----------- library/clips/clp_demo001/video.mp4 | Bin 0 -> 33456 bytes library/manifest.json | 31 ++ library/sounds/assets/snd_demo001/audio.mp3 | Bin 0 -> 33062 bytes scripts/build-clips-from-urls.sh | 88 ++++ src/config.rs | 3 + src/media/downloader.rs | 2 +- tests/e2e_url_to_clip.rs | 172 ++++++++ 11 files changed, 588 insertions(+), 270 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 library/clips/clp_demo001/video.mp4 create mode 100644 library/manifest.json create mode 100644 library/sounds/assets/snd_demo001/audio.mp3 create mode 100755 scripts/build-clips-from-urls.sh create mode 100644 tests/e2e_url_to_clip.rs diff --git a/.github/workflows/build-clips.yml b/.github/workflows/build-clips.yml index d8fe775..3f19250 100644 --- a/.github/workflows/build-clips.yml +++ b/.github/workflows/build-clips.yml @@ -3,25 +3,46 @@ 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: "Topic used for X/Twitter clip discovery" + description: "[discovery] Topic for X/Twitter clip search" default: "ai agents" - required: true region: - description: "Region code for TikTok sound discovery" + description: "[discovery] TikTok region code" default: "US" window_days: - description: "Rolling window (days) for TikTok sound discovery" + 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" - min_likes: - description: "Minimum likes threshold for X clip discovery" - default: "1000" jobs: build: @@ -29,24 +50,35 @@ jobs: env: TIKTOK_RESEARCH_ACCESS_TOKEN: ${{ secrets.TIKTOK_RESEARCH_ACCESS_TOKEN }} TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN }} - QUERY: ${{ inputs.query }} - REGION: ${{ inputs.region }} - WINDOW_DAYS: ${{ inputs.window_days }} DURATION: ${{ inputs.duration }} RESOLUTION: ${{ inputs.resolution }} - MIN_LIKES: ${{ inputs.min_likes }} steps: - uses: actions/checkout@v4 - - name: Fail fast if API tokens are missing + - name: Validate inputs for selected mode run: | - missing="" - [[ -z "${TIKTOK_RESEARCH_ACCESS_TOKEN}" ]] && missing+=" TIKTOK_RESEARCH_ACCESS_TOKEN" - [[ -z "${TWITTER_BEARER_TOKEN}" ]] && missing+=" TWITTER_BEARER_TOKEN" - if [[ -n "$missing" ]]; then - echo "Missing required repo secrets:$missing" >&2 - echo "Add them under Settings → Secrets and variables → Actions." >&2 - exit 1 + 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 @@ -64,7 +96,20 @@ jobs: - name: Install yt-dlp via the CLI's own deps bootstrap run: ./target/release/capcut-cli deps install - - name: Discover, import, compose + - 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 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/README.md b/README.md index fb7b310..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 - -# If yt-dlp is missing, download it to ~/.capcut-cli/bin/yt-dlp -cargo run -- deps install +What's solid today: -# 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 - -# Reliable X discovery requires a bearer token -export TWITTER_BEARER_TOKEN=... +- 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 -# 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` + +Manage runtime dependencies. -Important behavior: +```bash +cargo run --release -- deps check +cargo run --release -- deps install +``` -- `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. +`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,73 +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 - -## Batch: three finished clips from trending discovery - -Two phone-friendly paths that run real discovery and produce three composed -MP4s plus their real source assets under `clips/`. - -Both paths require: - -- `TIKTOK_RESEARCH_ACCESS_TOKEN` — for trending TikTok sound discovery -- `TWITTER_BEARER_TOKEN` — for ranked X clip discovery - -### Path A — GitHub Actions (one tap from the mobile app) - -1. Add the two tokens under **Settings → Secrets and variables → Actions**. -2. From the GitHub mobile app: **Actions → build-clips → Run workflow**. - Optional inputs: `query`, `region`, `window_days`, `duration`, `resolution`, - `min_likes`. -3. When the run finishes, download the `clips` artifact from the run page. - -Caveat: discovery APIs work from Actions runners, but the `yt-dlp` download -step can be rate-limited or blocked on data-center IPs, and X media import -has no logged-in browser here. Path B is the more reliable lane. - -### Path B — Codespaces (also mobile-viable) - -1. Add the two tokens as **Codespace secrets** on this repo. -2. Open a Codespace from the mobile app (the devcontainer builds the CLI). -3. In the terminal: `make clips`. -4. The finished clips and source references land in `./clips/`; download the - folder from the Codespace file browser. - -### What lands in `clips/` - -- `clip_1.mp4`, `clip_2.mp4`, `clip_3.mp4` — finished vertical MP4s -- `source_sound.mp3` — the real trending audio used by all three -- `source_1.*`, `source_2.*`, `source_3.*` — the real source clips -- `manifest.json` — provenance (discovery response snippets for sound + clips) - -You can also run the pipeline directly: `./scripts/build-clips.sh`. It accepts -the same knobs via env vars (`QUERY`, `REGION`, `WINDOW_DAYS`, `DURATION`, -`RESOLUTION`, `MIN_LIKES`, `CLIPS_DIR`). - -## 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 0000000000000000000000000000000000000000..567b796b831d4f48f8593d06244cbd5d1fe1c8e2 GIT binary patch literal 33456 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 z zm6CoQ$n{Tr+g+1g31n1J*Fz9_1J~!yxW8(>c0+$Zmd^72A@mq(K)LK z4d%sh|0eW5Nanw}bQiB90CK-hfL@FT5QR4YlKMnA5BV55C;Lc%Ab4Vq*8X8}4Xf-J zBsgvpGBRm(fa}c@&4jww*W=3LQ9go6_k=c3PvrDIXk3c@igH9pHP_gu^uO{Dqjx<~ zOY_I0)!WUDdP3u`F$<(?Bx^>W270Y$e_Fqth4sUJWR6>&SzatTrC&zzfAyKtPPst~ zg`6&2U2enhW8L@<<3<)*-h1pZXRHXC^@a5u$`A`N>*V=q#23obh=RdrP`or;5I2}Q z_j#I{e;^6@?pqQZ|4^_xWn7kLu9XxdW3t#Z68tI6(1V&+&S+wpS*6SaXIo!^gx+74 z5Lub97FX5JQM7)5Cn_>Ne@q(^!5(!~RM@!)OU zqwU9d{I|J_Zd^|mnJvk@e*B;}+Y5bBM!fQYF#S*Zy=lD(NzmX6?28sm8 zM7sc5I6**2+#HAuHR5dcMBsdd!Vh?h(*ii4_B&*R2)3l6pASLYo~THudG9fC>yQ=q z0+upfsOGsg@p8((ao7n9R21k>Rc0YVx+k;}cCuj8iSU<(@Z~Zes2m~xB9y4`@C0q{ z2NQESmrDp%$_SpL`73Dpr$JI0p2W4um!_M__7f80xc6#C98Lop0A`iQo2&pnco+h??RN8bkUv z(#qBUU8vJf4r5*-6s(#Z#vW?1MlXI4%=MNydjPC#ENj9zpj)K0r?jT;!nXOaU?!8U?ZU}f0N0IXF#A-GYFB&LW(xSYSCW31778h+oO!iVQbLgm{;okzOs|P zK)DzFnryh}qB~+Xz2)VI?ZF>W4*R6vwiDr$#Q9g0+phb0r=eeY$Vn&q`&;{BO$W^< z(T6VDhYR~KN&9yfkFmMW-41z@U*4AR8jB?7bWdQG^Ur7B!0#T&~(rBipHJL`rN>OZtFUY0j1Ck&M@Xa<#m_i4E)6H?N^Hw?tSe(*$QkK;io8 zbZb#7iV~PWY%l(^&#XvZ?kFl}VT~YrOFtZC{cc>O5Yw=|NqM5-9%D{j_sGcLu#ZNr z#U`cBhN~^ijQ(b4U1Ef(j9<>0Ilj?!n>$VB7?iA^Ct>qn5W1rg4v>iU0+etP0GGHs z;29dvDR~*p$%^s}uo4#nTr@c_9N-5&==C;|BI9Z%ew6W7o*%_-c*nczev|c0c&Tsr zO|r7|q{zPeoK%(GTJNfR;MWm%uZ3bWj`n*(o7g9xZN>hkA%cO@pV%k8zckczx!4>t z`qFu>3E2K#`*U&R&C0>xo zG<@+yNyeO_#RawGO18?T3?}ZP%?%+Ewii+p#;8pi5|184go3R}AK^8Ss#QGZt7p?5 zELEZv=JXmF+CAb0Cc=_pn%~{$~vVBpqP@*lz$J z@014E16~4-GR2(8Z7du|GC_bbybP7&14+z5MZWy&nQojf&UxO2Jfo3U4{zxd zf4Xgp`bB>7fyV2zTU_6lU-ch160q;=Qdlz#9quPvU9ghcS!qo1%otrSWhs1s*$V4? z8c=vlryEI|2*&BaOna2ekjCxj0ESY2Pn!?eCy#U&Ah(9U1;JRIxs0TgIadp+Yj|uqlcbb5??GBR(wU_?aY27D=e&#KeWxRarO~ zR+7w}Y+jik!yAS@jD|>2*|meiyp*dx=Nw&vCS^*CImu(d4Jsyzt=*h#DUUbzr455l z2W-IrpbhzKkptmRAK& zFMo_^)Cg}_`nnP7+WGh#S(eTJ{2B73JztHJwHjkX(WdTX&(o^dqjYfeDr2PGHk9Xy z#}>J#ictZF2QR}Da|R!?qPGS2gbp!JKKzk~YW|FJVBeZ+_P?Bh(v{-b$jHYZ{k2ms z>$`!nQk7DU)JGw!iJ9XipU4n=#-DhZ&{THO`mxL1yKdQ+Z`VTM| zyEo8%I(yLUCbwnB-d_()OLlfNGb5BqAoxA%zcw8j;dt$-VIhB@;rzzdplEN_GQGGm zPiVk(7M~+{b1Vn%DZ%AhZ0m#DZK{)#-;b`2WvAEK?$CCI83!n0Hw=HYQR8urIo+)W| zt8*`2TyS5j94;)j;~yQ=w%Hn1(pwwc6S~1ZS^gu+{USsfU-tw1l<8L5;;qI zRvQli7DA)fd-)qpxrn=a{ow>)^z#NHWFL6L`I=3#k z&;g9~pr}B>m(FUaDH3c#xJ?`G@}rT!HOBArCbtE|R#ZivEt;+XvG(${Y+A9dm{xrP zll!=)Q4qE~>O_JY*p8~gld+hmi6NbuC!876*}|HG$-5lpQ7&(gJbb&5$8Ku0xGp)MpdcOJDAY|m3tm|3ONG-C`yd(p4LrrLnhkjAukPiQCnr0<`+9Faxc z&+5ON!c!3`G$d<-D!b;FMz|qE{^`V@7h;~l6*120kJt7GGTX?!8tfP?A@B(!`I_Mj zqz_2IyCSS10&r%?duW^(vV;hvCz39Wt%%=i6=hsVJ1ET_Oi{QXk&(G@kp(h1)#H!h z!TGO0tnb6(=cj%RvGn=){P^{*j^0;&HHZXBk+~Y#9)U?>oHVrsA2Br#$ssPcfTpx3 ze=iFY_X^Fv-eP5pzZlnSIStuY|$NB$IW#$t$1!vl9tp|*OmkxdQ&_(tw)yT zDN=2e`h+L(EZKz_xQPr&n8R|+U-cy`IQ4j7(b`kfL>yoFk3%5HfTALK-Tn%=oS2Tj9w=so)K}Np0=~ z(jfD}dqPLiC*^lu?wa_^pHYrDxb#}(lES|#4s#=A73vdwm~yR6)`yMKj=d$iP3>XLV%so|F0Z|xI%bExl{K?>Q^ zr(*K$YD2!Y9O=`ycvaO;NAuW3qPZGL6gpPBl4oDgCytvE94ec1bA>bM+Rak9QMpBs z?a3;RPNzCm=*)Y;4}at@ic%BJR_W~_INMyc&vR2xvwGs~z@u?b=zH`@!@Y+7BDCLd z$$lF0E6N=-)Emg8&3bAom^>SB$ccYHTD@zL|45DPdn6VZg_VF<)_4rk+OAnZ7|MI%#SH zt{#@U5Wd`GxI9{Q2D|OF0vJko{A-M@($;)1hj&%C$1>Lt4dG&ljLc^M7v6(&U3Uf%>e0V0iT4z)FMQz3 zxh(6#+`%x#xmwl2i}ow*H={?Gw+ibHoV`ArR}}{<=@)J8`WY6PPL;d2Zp{nNU%W}@ zc};RjVSeU5Bw;o4tdRRgg8iP*RqV;cy@q~QeMp^ZeqbLx{^jNLirxtK7<`~m^P4ls}eQimXk!Eq=BGK(C}DvBf^1aL@; zqj8SVN`M38foZz$D1{^dK#!geVXW@EQs>wA&9pvNBI7d_YE9hJfVCk1MP7ch**PAIM`^mm=|ugN zYBW}M*`AwC)R+Fr%iZsLUHdtYD5$ak+ov<1`s7X`^Yas>b9j1*B_uQkSQ++-sYU9} zA9i0Ld`oB2j~99SZkYM`aP|}Zk4Fj*tVT#4>Fzi37N@sV&DF!3Ar%cMQc(02zFw{s z_@4>=iR;cL_yHJq$q2bq6F>;a0UpSr0HGs)Oiy-7lU64I-y+ ze1X)~dhOD!cl(6@eT;)qXBoa{v&X7VbUQzez-;K(+$^wwHIfuvo~zaS9s|s15?Ck7 zJ)x7>lNTCdcTw(F<&@;&PD2+Vzw!{bdLAw%l3sJ^$5s@y69Jf8Z{~WO>Y)!$4-@VH zw(o@atB%Hbhkk`D1SCMYE)DznL;UxGO%+@6<86qjc?{E*pIK$90u zh4uG6b46+sUM^~dF!2OYG~c(l>X!>bKc5`1(YzDVieR^a+HHLz=d!1upYFA?m~gd} zcjv{Gic-L+i%zyxO}XVsK>Yf=rPT-JBRviN#WQ;f>E)RSV~I9})b5+uGHy2qz#py0 zsUX5Al}50)bT{@pPxOdzVXv>dYAk_e{u{_pc9#>DWb?56Wy%O7Q=keXe<#NnfBU5^ z%4J23vizq!lvi_~hkg-y{7D6?>Zj!BQ!J@2oX(Qgsh7;C zUj1OTK52=!`fas&!%cUtUcS8NbV~1CxKynKf-4#V5`d(E$^|chr3&JtG)G35c>a`T zSP|j-qkde3QA19J6+AM^&)7U5g%2iTAoy{r7AiI4FU4y&S4Qv;I`P6&E1zn2gY)vd zDuqG@v-#w(4fKu7k8I`RY&~X+6_X=KsItqfQ=%YL+q>JlBxeS**{ww7Z`rH-V-0f@ z(HCttV0*@#(woj_?4s$eBBt4wOE0v@7wa%&B~>@_K_V8jhgujTutZE^>q?TIP~~JIeG+9 zR2UK}k~AtMLNQht5;B!45p02RgMPXOyO^>RlOE$Ui8P`pg*4~~WuB_5AEhh?2zNAr zHDQ^4r>}Wp6#mMo@HtU}_IWhZn;=CL;)Q}%ZRKjd%(N-(>~(GLcMVaasADd&(^=2y z{3*#(H{8V&=}NdzB@Y{o5`+scSSr??$V=7)NQ~2#Q!N`rl556L=Qw8GO$aqvXE41g z6vq8*HTt?+rTuJsWIbE2u5D$UG5I&4|6x7!w@bi7KTiO;qn8Rvv4au) zc}F58S@*Yd#qO_<-gc~`Q_j02n!g%BWZ`FgOAtuU$dEb5kRrAPvv%xpK=6oIsM<(^ zWIT4;XZgwCU|4Z#7|8Wg)630Z!7$wg{hrWX*h!Ps-<<-Oyb`JU?eH%RMH@DG&A!^X zt(|@egM2y4K41UgBmQXC!-|*UJ#*T}{VF%l8@tktR0DUREnkDyjDEb;}H- z95|-w@FxD+2(|v#@B@J)@bST}h)hwYSpH*EzG5RY7;sT!2zf)>>FK-HK|>~i(-(oE zU_%l)?E$>iirx_k+bC)%c52hOh>n)^N1~=^b0%9p!(R9*kj@d#DPI*`UfRXP*f5(_uDab| zI@ZM{!B>K~P$HwkJe?av<5ZkhL^dk-oQX4vYaY>V|Iq}qg{N2Rn9dD`y`KO zHawg9T;VQZapV*4KZRr+P>g62gpZgWN`cZDheNEl6)~Q^@r}?rwhlghEebAoWHs;` ze}qt8n6-(Ym>eqNkZ~CDbk?~?Ln^yw#QJuD1>Tdg@=SzGC2Pj%0x>wA8sgyGa9X0m zd1+mfW{cdyu?i|`8H6ikaXER(cq=`17UvP?&1m6aT;Mh4PT#;&*A{q5zW9mdhZcQM zVqLY}pkL4tn2`oufqdg9sUFveYhL~ES zX9*tRvTSMnCiFMK{F}qO{;?YXFZuyM=u`s;12TXoG8d3?VQWZ%%o?B?5WyRbKhHSn z{}4<1DfbVS=ee(*(Z15daFwv&?1yW1LKeqDHCTXs zebW9h%lHg# zECJtAi?Vbnb@sOO2?o}*6`}CYLu1)}`4|}A7%*`CsD{WK=`kRF5{iz%Jl1^gx0wE# zUbN^Ufkr`2t<(p6$n0jtw3IMVVE^E|+_}KfD8sk-gO}E<%E?^@rZdP{^F1WPdfL{d zEcu7}wh85RvP0`fx>do+L)1bRx+#MFnA&lkjF}UkY7!kHzf62R7}{FS%NP$NVoX^3 z+T^b#P&R1VVyT{Na%W{AjY-BbQ!){xD2h;--F zV7Mo=gME_lM;`j+<-l?sUI-883U$~u=7m08v}o`)}iTQ9+YXfQ8v zn?>hPp%59a@cFt0r59A{TJzDvzkAF>#e;fxU-Hl-e@5sUoQ+9DfS;K+D!hARhnG7Y z3e3#RFUs{;7Dht`#q%3m-SG%e4Vr8`{nVb8LQ_liWEt2;$mHSXW?9K zR9Ij;(c+c&g!ZFP>hAK;UmZNKQPo{-`?r_FdT6>N6kv&RlgvgX5!7h^rt5N8$>_Oy z>Vx^VM--d}tP=c|^2GBaACF$Qkrgl}m}jQB6TZ>;ATEtuN+69UiVwqLBn|;PveEPE zCZJfY$I3k23Jc+`d1xt@w`owbiSq_o#uUc&s2Dj2X~RLKQj-3Q^xH47YB%7wk+Zrp zOeSZU(fCrIGv1}y;yb()Y>?ah)!j2 zGC(dDf{4OuO>fe`{E(|9z=$q0V&u1k{+Y>NXTXCF8x}Y(DA}D(6oVI zKOA=OJhlrcTe**&cwsTs2Z!&M$Lro#Dg{p z;NGfIkAD-IFyYbv=38xkQ8v;nX%BfR;gD>j7ce!&Zsa@lZi%5FFUD+*N)^j;4}_st9gmZ zs&Xxv*831J)=O5@6lhqsL}nE?QR#)TCDirTtyf$dBtB16m3sbPH6H+UkmUn7M<`W5 z3{)El2Ra)YVUg$&Vc{MNgB~McID3`7(JBG)lG&Tu#>>W2$@zn#D6z=H?QMirl)c!C z;}O+v?8^o==+&BApHJym7KFB{9c~K^&9PP#9m?HH=n(s)&+6VN{0?%~_k$$A8+dM` zY_$(?KV)Y=yp2oduC-Y^mu;*bm)4q>_o1`qMrgH)oZMF{*|=y-f+v6Wpi!8f6PdQW8dV@+B$Q%9&l&l?<>?&G$IV*k=v>4i)mAwglP6*_lM- z%JJxCEY3$Zg&Y?y&^c#HZhx6mbK&GDz@uR+pP=?e#a$LbDbYhhiIFj>n+O-wj@+DtB zyiM>oX=^)pD)9Z)*?E2RX4{9X6@BJYSko@Wg$HDr+HFPn&mdQPUpW0e>I06ix+`t} zPD5txh7CUNQqFcVPfBmz@baD}OO0!siyZI>^9r_ju9RGTAB+s*5tf+!^wNN3>B_Ri zg?r(8qa%C~`V8&`G|@i}x&%*c;uE-MW{AP#_U$u|o7sb6GvNS37f6)3_>`H1I?Vou zdH5c5Ouz^#nbc^&T-9^*QEL;8apUqSrcV(GWgih&k>@gpT{Im*_8h^eR=ez%$Fw2L z%lh*jtJd0*yN&|wqm@hN0)h?_4hl$KhI|K`rBWt7|pSpNjY1tRYdsbPH;>ocS^*hS_ z8~=8989)J<0PvtXfKcoSz!~5Lyp+l0lo1H$kJT?FC^ z7y4f?LR*VY>OI0#rqv|U3E<0c6{ZKC97hjsOo^wv-&D?6&`pXF8jy49sQsZK%#*Hv zY7c?a?^~=PVSie=2bD{t?Qd*lN*zV8T7F2fSGCZYv{*&9e0Jw7 zqtqGEd93KBtLSSL_2uo*E8(F>C=UWU3f?f0DVvw+S((Y+$* zGwCz%KU-ViAdQt2KP)5ZkDN1$kRs;{EkK-}dLV}G5h2Z2i96@5{N-c+GfT#t3pjY2 zN*Bq~!d6w?>Jd>HMgxp!yR`~@+3~ZHS$;>QjO5M-e7nvgDa;>)NCv1a>e63pde0Pn zvmZ^aS(lvsUkCw69fkm6M>RkU#|Q9sxd34(L7V}eW1J2s?m%Lfw}6!6q9VD79@MD8;G*W5j1~29h3KV*8Jj>$x%bwK zEl7Vo19L`IqV$y2%9^!XQRGH`ZA^$VGyrY}S{eX?{(zq_DGlZ_dy9kh+~g+4;{&#h zSxR7->$s{}J&uJ$mDNZoOn}7%s*DCA#^6@4NhF;lPjaY4A`itl*051(53WHJ{mNsh z*^ZeLrATj$Q5IR8%1k8Q)i|qyPJ>%lhh(1CRVA%LxbWdqM4iqfxF7X|A4W`v_26g_ zZ5|i0NQXiG1g!2<*$MHqN^RI z*4fZc;a{XB|NLtawIq}6>9a38{|lktssa!}dH}LtIY8RU4G0F<0C9b`oUJV=oJD<4 zfy@9`foz|}ibP*JEQ(WZPejVN90ytl-Ky!8ddnS$xww{N#O(dd`na3M+~PeCcJh7p zbC@l+orMTdwM+K7{XL<(<`y@EsOW&HYMpXRKdUbT?d z*Guw-TARKhUU~9$*KWYcp!uqGoYU#$ZX;<+O!eyV4jufwO-bqJPH7Y{aW5JUIX$K> zQwZUhQZZ-RA&Oe45{m4PO=$>}=PPkcF;f{#txh`7pxA(X^I>3cr^(pxM+)44*!7d(r3oo1Dbdd}0QKcN1Fgup% zE8~oPdY&p};bSpQF?XtA8%8F7MX52sWQ0&R1&EK8Ey@vj_9I_F(6&Ntvs4e6< zBhG00*6tZ?Pjucum0n@kpbRly$j;^*PHa-|z8EEJ>D^~&zujswuWcfwA!}A<0Qf=E zz}-|9U=8;asPBq{q@ZjFnB1faP@v>Mg2X=a`Dp@2A{^t9T*hOZ7&|~NDQiO4$6CKr zEu4xbo>Yavl$BRa(`Of*J{}V8(==v!c$0bJNg7%gqrFG z?Stzz;5Xvx*bO{}puRha=VUKw#%tLI5uc`1xofltxN89M+h^03{UScRn zwvL-*;cbseAicJhGwPF_d@MsJJ6Fcex?R<%vw=S}6nlze`}eVJFy!9L{ay~u7xQ_4 zhz*|7!I2}@+=}}~Gu%EI>8u1R(w@ZQ;8SO<2#enon1ML=m8r>eWqrivNg6hbA(m*a z?XG;#ize$JV~U!;iOfeNjnY91dmziuizli8dtAEJN0ACfA<|t%W{Tr0UJs_n&Vy-{ z^}bYs>41wL2FN1Ws_yrfO$SwTyK#vfJS@F&9f|E>(YNh%MWy1NZ(UI4h_rlZoxbDyQ*)-nC+bmR|oEJ{h~mX^s%x&FA!miC^|A@(Va^&KIx-+2hG;(p}xS6NO^ zqpD8DN;5|KO%F>2Mg|eAAxzC8EM6!RmHQpcxT-pmR9fw$3QUtQ9?hxGtt`7%f|RXN z#a5H_@pq*5?{>Sk4_#KYIq9M>pzK4~s{9y)lo~^HW3Mo@^1hC6yqH#|rTV0(rs^5p z1d2j^64k6WjF}qM0$|W0s(M!R*9V!-GtuuhuOjFf$WnH_HNPUqTze6&|6^TQr_*SD zJ3mo!Zul=a2ER8xn09r zYWL;g60V{;Ywpp`rmu6(4sYcwW)kPW4`|5)L31Cq^aQ{(5zp~K- zP%)Gm&K#;0kN|y+#AuQfJ7S~lDPThq`_7~U3F=KY&<$n4w<3#awXFxErX}weK4E$I z_WULB`%rs#ci$IuI#^!s1Z`6dOPkK3YOM?6Gd47E3@e%M3GKw59N%k5>{pAGu(AB_ zgi6GAnFgcnW)@$#EilAYqlplz=LqujFD@Jeo48rLH9KBT887zjD@|ml7SS49Aur0I zf9=d}6EmleYL=?olQcjYoiOS&gy9H8VN(hW5!C2l(0(pb)EK|UV!(EZ0UzySs6hQY z#EP)|H(>(Lt)}u}a9$?5w0_#{#M3+HHH{12>%$fK?<-io{%|gKx)ywujqIvS#bTD? zl%3K$K2KFh;dC)oiRGjjgRpui;9pVt$t5ST?no+*eN=XB+67TkJRs$e&WHdTpv6kE zR*j#uTJfO_|71_%{OK!JN_&!M;X`rQu#fQUZn4=wp_!sK6aR-6F;-=()A3Kg3H?ovd4yVrx2ZU-xdLWUSjBJ?oq}FhneddX8_Sbfz~D*H2w+ zB}Hnq`&+M7{ZXit>(dn$)M;U(kK1k6I+~LAgm$q{j$wa~a)i@&W7{VKze);=S1ybn z-tSKcpw=#(O*Wn{#q3=~x9*l!itS`CX&&qeM`lNyE2YxASM~5O7_Tk>ADQayM~6L5 zcQ9X0rop$$SEBHGYwWpS>19@W9kwWMvZe#fey;(}jxj*aUl4d9t_z_E73MX6ZotbK ziUz^zTo(B3ir&lJz_oH4s8fO=i7CZE?_JHnc+;$sYC4RZR4(Y4 zV;SEcX*FL3UrtS+_xX8WXZ~1km0$!7IJ_;!q*DAV$)o<=$l9i^T7~&2Qkl1@U1JND zB#bGxJ(665gr`ydSddQdy)gt&YFSR1d(p${||%+;K({O;nO|g2S21^a`bUg5`Tc8`vj5>_h|qswogKRf4dOkbgzF_^^;st&&ycwiii7 zYkTVHiWe_n-^ztwH=>86%&ut4zFxfI^Obm{yDU<8Woh|ytw4dl;Ua8>yWB>@eoFD> zLAk0pWo1}_Z-xWMM3-6!zrQw;lYbKOK!7+pByb8ZJnWqiTuaNuc(aIrNf;mY+(d6L zj!`BSqGPOeB_2H;66I^-l%2q@{7o=5K~y)Ay)#x8lDrvp(%P|k+cfpP;f|f!0iI)4_ixlVp#R%X@a`fWBFGQQ>Mss8gExeB z1dW(t%HW)o?xze*>K1J)y4?+M)v*6;t}6n;fHg1mnsM54eo3UotE_AM8+-2_;_y*C>B zXc5G(e?Uh!R06e-vAUG)9yeQXFJaZ_qz^ynwf34&Pn>Coo$m3=1 z9n~IClDc)nl8e@(kV7?cdM}}#CR~&<9<`A9s7KA?rM$#k+tH@;fO(0|~L8wFs zN=0Qddrm2GUwyDir7#1<#fl=TF?h@i<;q9tkmAuA1a|8OSxc8?M$r@nv^6$D)t!qX@6 zhUZm3I|LX0GoR5o*YFdrJY24xaZZd_pcGl=Ob@#|5cJ#`sr8tucjk2eBXLJey6Glm z|4GdJSG~coAUSe$rHp$*U;p%Sznubs)9b%$=%%Pu%6o`MePOd!rOcpH@5_|bDzgi$ zpEv8nM-N_Dea3}~oi*E)v~MG|yqOywT$)+q7}s8<1jVLfk={=etmF*a z)pZj2c7+FN1W-LN*v~nQ8d?PwfKy5fq6LxKtYDC8nr(u;#R#&JhRmdh8<%CswldME zP2)I38n>#aulcWMgesq{8;`zUV3Tqv?34Z=@W8@)&-V-FaN#E!)zrsm86n68-?<{| zQ={2T@!m`ak*7gtrk%M{qDNla_C@h~jyMb6x7 zI-Ts{YOXU+El+a2$#Rovq=xU(13Im(!JU#neJ(R91YG*oA8i3H4-8;)g!DM zW5lcvmPee7H@?;vuG-Jr*;&3mYs=I_-Y+Zte8T;k&@a#SpZ5TA2QNVBmk)@F2?L%1 zK@eh@H@r-3XgojrS|E=CI{1=@xq9TdoT#~c_c<0*fizSx_vYJPx>TT~tbM}0i2jT3 zwk6N@Q;A89-ICgSLi^aK^8ZX|<6cAbzX-V>p=HL-s-anij~riyps~$kOHZ`Em$p+F z9c|e8B=~rmMAOzdQRMkU7oAF9_Rw5~WeTxlB85~5%3?WN;kO(K?)FM-m-F_5gGPfh z`mz;W%gKzn;f+NOio2wNLufb*80eIoikRP2F^FpjW#|S#oz!1D%E*$ zJe-=dLkKh;hPpr)(HXD3+#tuA=n+{QEZ@$t;c0WATxXm=6(sc0 zzHu{lFTQS%hACbqq+7SNrRy5c`X+s6CVg8gpE(tqRh|6>f_zZ#q*u-E)w4UDq4r#{ z3I*2iZphrk;&AYrk=gF`@l{oX>u*B;*}?sO#of>k8$d0Z35df1fKS&m2qlUuug(Py z?_-qb5PI=yzOsIT#wF zbN`WtPWAsF^lNMz@3a~o`&;iXFBeu*BNbs~X}6xz=oY1DhdNY^hZ5(KV$o_=uCG)l z8{UKAfI=7B>Kac-sGujc*OCXne;tC8zgMoK1k}OFW zNy)h7i*?stLMe31EmUHszX|{ApkR|6i~h!P)N8-LaP0od@=7z`1Jb)A=&}g z0$3>AMuA*&##|$6oER-YE|%Dt<6(C|V7qoZLxTE;@ba)?cOWX8v8ks3-L zpX&z=2|riU+KF6u+f(L9qVMdn zJB8tsH--mG&M)c-h*T_wn&Mt~QXfcPM+IG zrnD;WiLf{|msp?|vC1pM7pC|!=#0!i*4Wk28r!y%W&alAYMU#fE>vSm{FT|T7)D@! ztARoUUF5}cdn<3%Lz54=3mQ@(v#}pQmH&sT&)xj#!wzDA)h_|i5aR^W;h#f{`=a@_ zT3++j$z(#z;Ku~GZ@87hx#DfOV743|x`8lUQ9%snHT(oY0#;jeyMvh@q0L!C8{2ER zGVxYQ@Rn1pu(|6$^H9CblfWAe=Rcd^`22So`fEMZQfAz_t+&M~jH~v^V0tYfLL^V7 za)EZsLhiY^>%k3OoL(%PAX6T;y-kR9kY7Z2mqb#;HZG@}iM2W77ECWJ-lKhUiE0hq zk`@1=B|@*xtv6&yizr4_qF|+v5Gv&_3YCRxg+2{vhUOqio7l%OTBrDuS!=~knaCj) zTe+=fhS6^;l0|sgQE!0@6WF+PEYD$~FZ8o8c?GUu9Bd?bfsYSGoOYe~t8P}bEbYBr zH9{lE6kGAzor z>-RHsH%LlIm)8tKcQ?`v(v7r~;(&m3HzM5#(jnl`HKc?fD2)gxA*h7z;ogtl-tT_i z_ua?d`{Vw^z#PDF@Z-PMf30(!>q_dd3a&V{Rq^G`tu1MPk)BsqC{M;~?dIPq6yCs| z`G~RQbh@2L?3&PS;%TBQiV);i9-_efD-ZpOa+|!2L7VDc8b%4udytE|$&S8E?yj>$ z8mFtA&QsdTtFpRat)ZcdI8l|YT&ymh;6Ak`H)jd@^1OP}|^C z^xd1Y>pS1BQ=XHEub`^CH?9$6r2v+*QVQ*~GDRD-%7`$xHYM?PX8I}Tv_`^WEfn7G zh^6{WN$F7eX_>K`cL8-()FiJN0eXdp|G^7!*MX<$cR6Ekl-}NR{J1dlGFb7AnRI+r ze#xAgGNIR-2mA9L1I-r?R2vvCn~c|6%I}S~%eGu>iNE#{Fr$Uss8$zu?|=GTbSzk+ zEXcP`(#PS4htr7@bKa76sX3dAOGssu!EZwURUZ25J%9#*4v4^$0NWmMpgAlF@=SSJ z3@#omCOxzRDMSZ}&kGA4jKJ1vVHxkC%X>f;E&utZ=UO<~tR>YLUVec?KoO-q|Cp_k z7iZz@5t-AQ6cvNn+Qe%@C&XvB-Cm(zS^uh7$w&T?hkEp1-h(ZkZJ)l*{zk2}kpwh3 zM;vBq$e9Ezc_kXqI6mKUtKv)vE>Bh>WNdFpoz|zz>ChRT=YEU3Z-;a`R>=9#rC*hB zyZ%|lx}Z^@L`tXvrRHs+GsoL35jic^l)0KKx=w@mHaJ37qNt5hqTom?)i6)1AdEq4 zhJ+HwbguwM>jZPFD-1ci^q!~aWpg6*q#s?@sYT-QZ^NDl%L%A*3Jj?XY~&Kq_$g$| zu|UiGyg5qxIVFeF&G90plELCiKbmz{@>F#|6LmZfk{EPh(01(svsNEG#iR!5SQ6WZ zdy+aBvI9y8!oDJY&27zE1%psR%0Udu0(QUuZ9<-y@J>`2?g1EsegK7X3=mJOAW<_f zCsF3e1c1W+2f4QPiZ0J5^dfCO3$zzUxLuw=*;5Kmco1{;k<$M^2d20B>&5PukH zOv%y2uW)kT>S;{vHKC7*r$1d$gns8CGQUI&;zNgD8oG%e%IhSMkC_#0Ye``OOCj(} z3yY!Dmlb%fXWarJl*O>lxuPp1!vJ~)=x4uZ&-Xo=(ge6_Qp(!nS9|uu7?2Nk_VLYo z$?wz)-cX$D+~}rZ5T7~0f0r+r)J(0+7GF$Cp^xxH<3)I&lOSR+z94pRD|)FJIw#b( zVdL8ju%0=b7lQ;o5|0U+97W8M=5MWrsdf7~wv8`ID&I%kPFOj5pGKK=4tMsbE&F1!EC%ZHWWG`aQ~ZDQ3O<;hE(w#F z3%rXgmE+zVH!Z?*7OPSO^i+pNnBqD(lZT6i*-4OdOesx`D*5CUOBS#dTs#kILlwL% z!c|3TLK)jE601UL+8(hiDcgRU8U{9+0^3Gv!5;c7ivm1&?t>=J?aJra%;BXhxVX;` zzN&06qM>ake5qux335_jB2ZfyYBEy${xA?)H}Z(mpe>6lXdC-!^@G4*I}TX0{mZ-Bu0)o5|+pt z1g9~T!caef5A_dpWnRkZKY)BFPYs#oI266C&}cX&<tb$KX{8A9MT^(H=thwBH|6%F|rqE)ktsTSa7P#@Iszer(;!% zBxnymvqKUzLUZC+G$2e@=FK<*na+5bui;6tzidT?JLu15ek z4@-dxD=$bHz0{HXse%p_daNS-tXWvgOwdLHR(A@WQwNf+Cm7z$yu%46y#HBLIbt^! zwcaJ0-nzyaaC2i#YOvRA$kr{gg)k{%#})UJg;k74J=NffXI6q2cHy>TTuW%3@U+GS zLH@UdNb8IL405~LqUiI?icmu(0-o&#M{>7N7D01`$5L%7cKz#4vAEIE^wE1P(O3{y zJS9F{5)paKvf=vS^#L5@GeyyA!2x?kMs3nqauQ9DyjqS*XVMzCShmQB#Z&D;R`;8% z0@TY~o%Sp`Ce0uuRuW4b<|rj^fpQ!*zt8+lm%D+iaf3%5OaF;4WWCinFVU>^J z9rxrT;^4r4uDDuxQdYIN%Z!DfJ`h&h0lP}4v z;FQ7ot;S1(C|cKvI~00E*7Fwr7_zKdFgvmugRg=biFbo}cq(4=-5Zm`Nx{D19Z?bO z(53@NVyMDfuyo+VcqXB86tZF8q(sveRu|JmBt^q4$?Bh2vZrI^zVP-+JF4yP=}%WU z8%8x2>py+HzgX0ktwT<+yqSdx{x{bl`C#U zP70%Ek=5_HQ)6IpE?w4A zu?V{+v=x0?>MFzYcT0h+@LEHTzuIybZa32aUX}C=)Afpn-_Cnos5tDs(J-Ql-rFPs zH88CxBl3O?zN)Go>|0V1+i=Fk{b0}@16s^rARniUU>@%cnU%c?Orvk~H<8j?` zQ!xq>N5{+b@cK5=M3^s|?C$Lv_pK6FbVvhXi^hb|N9RSDVTvFU@FaS47>!2^k5xv9 zZ?5)$a32g#7HeS>zT*{{;avzLW$`ZbDOm})b7$ENXV}6i*}wK@c(NOEiB?=#==Z#a#;hmCDq=i~^ag z&OeO8(@zPGYSSlbwQ;E^A1VdTqnBkjp7LcY$ZjOCXxufwDWb-A4!J7N&k$|`0PO;Kz_6nfYC zshxfARv#FS;{%o;C+?zbrmJBGp~w7#nsn+eGI#v-A0hs2Ki!PG~Nm~3N`%(7`+Ryk7cc~eKJI_1Ko zov(vJcJ?3AiKJL^O6-q=OIh!qhvW?pJgNXjSauw8Sqy7S>Ql_k-uV@kL0P&3(m#aJ z)+Il8cUOVpn?|n{Jv;Y%f5pPR>i30-gypw}P-zDc0r_y^JN0UzarTp9#*Ofj0vG;p znR5&>;}LRPg-PQq0tZ}~K4hi_XOdNqj3EI?2I+}L&Nm!3p4Yp+$7#IrB}y{1RJpxf%hJ4+$b$>$QK^)usRP_0{6Pn-0^qzRbWVIW?beO{ zLv{7H+0MVVa=)USlg&(~3lIV=2Q7z_Idx zmmx_{jSzlI|w69SmSNr5yOYX~No9;zwtFSSpTE|t(X0#!nz6_{pyO+BLtEV1?B zz~}&B$`_~^TDaIZdrzwDpuC|oQ{FAc8=LakZp9mIJeT>ABz(D%f%*RE3NKYq6q$hJ^WJXu2)IU;M%Uy~-(xEifQnLuDaDJV`&EzxqV+%)NXbLb;LQg$pfS9!40U;UL6N z`G*fv8v(=fMLc1`={NsM(hp(GLQvptPV4lUl1FwAu~6rN&?I#4P)&-vouozt%x8yD z#<%TtY!SZ+-m{7Qa_#}CG<%-rn19EMdUW15)g|BIp?t^5zWc-Z?Wd>IUu=t)D)T;j zn;AEDn5}qRiMxJ!^!9tk&X;oMdcL@jck4gjAB!tHK27}Tf8-wUSzFiVkERbGfm)Dh zf#3zw;7|xAViKwq7AJK&b6+Y8Z6B&6y9OMi2?17UN&wJn0Z?PygkT2n3s9hOT@@(m>kqFg-GV4O5b{<9rZ4 zbuzI8{rMT;|{IjVY~y#i-e7iJdCF#7t2&aC@mG)~8y>r`5L32Bm9 ztd9j)fqp*F+k_h$3|{h{a%nv*PsUdR;UyTja2J%3lfbKlLn)5naWbN)ffvyTeQ6W8 z1o=^@7%okyH`=g_G!`x=S}3T86jW4nJ1wRMV|DTV;O^@Csp2`@ z=F}}XOU#@T660}HNo1Q&+8thGw8kA@eUxg>sK00Ulp)Ds1yO55;Zwc3dX3ZlxKT1b zQMY`20&lcOc2~d5PP-(@I~DkM`z`=UL>6EPSH{G(v>^a}uzU6x>)}_5dk@7Pf3k~3 zS8xndaNxI#wANOS#IBG6Q_g@;V;N$0u(qQ@sU@SUc%|I$jf4zFAu~yL#A%psNsEg{ z_~~*#%rFxY;2*H``wrm}pjo+3ch^PL8J6+xuMLgoiDxUW-RPHUzuY!C7^R^s51fA$ ztPGqFX}5puXrJ0hd-?Mj)MQdMb)0*SJr`BN!**<_IWjlON}9^KxgJs}g{`XoE*?@< zoYf`9S82P@+tm~W+iF2wFxm^aQhyS~aqoHGJ!h=SlC2mBxf`zq=Auc}N{!o52MLik|tl%1XV>d6!~*o#8Tef6k;@7FAo@Rl?k`5nF+(8~cYJJ8m2OJl8ZfiGL!_&}r;c8eiAWA(Dh-(9M2~WiudoO_X=vI}`>vDyiXF6+UBz~F<9Ry$@DC|@8{fFM z3eb&?q&Xtwp%*%3O;(rpV0@)RStUN*it8&nwH5NSFdK8pnhaaXNq)s&( zSQ=_<`xSf`_|nB$e90DrnqV7Af?DXI1Hx*_<*g*M)932!>1{Z=KooxJFUt^&t09Uk zi?ZQ<-O<)UK_1*Wqbe|D<-Cv-Gxi~VD^+Wjn=D_fqw1Q_x5Trqzw*%UC`b5@C^tLa zXkb46HiOuiGo#1Q!n8vu?t67{(WVybOGn4}QhPG{Pt1C!(9M|xLzAJ{Gv!l0xfUK~ zrS#^TeYmr2xh{b!%MZub^@g^M#QlMtj`#RAJ;FiH>G$|6MC9Z5++EB|jr3U&%HR(O zdURG_N_kK?*9jU3ZcF%TrH`;6WCLBFGu2TuJ}y9n`srqvRUDX9Q;ty-c5Wx+YVrP3 z^X0qrJ=PcT_m}em7b2@Cclc?_9c(_n6>-`+^+(-@sd2nNv?Rdr$&yxWfWy_{J6$#M z`(fFKm6n#vpQiOf`=4KJ(RCZa@}JkfGiAB@{3gROAjqS0@`i_GQFMCXA9?8S!bcT^ zj-G@^j;VodivwV#5^3NTGpR5)PM@+0AQ5x|j8E}>#%MMapUIQ1HsXALj5~v{GE@Tt z8M{IqxXelo!*?n|dj``C<<(glKDlmu6d}0b4AO7rS>PGVpm$O&v;A724{DtUUgWY zKGgfeYBiohd;ceL=ZIY%bEMII)>&1J6#5e;%~1UE8zxXLH)Ch6MwY-CF(WEyqKZgl zkXk4JZc-TH9Q~4ka-5d2*re|_Zdp<&^1U!^?ra#!ovho=>^E4 zYygz)7N!CXphDRQB7sta?o+(6)wuo8bPg#%Mh;2U%$|dF;Ae7Y5j^;=IK%6*aW2eW zj%To5a`ckcrYzQLVu%0jjnV0nX6Fw?5--Xb9M!AFCdsrB(%>ls9y$dA z2U7;2hv$aaXFME~JOd4~FlO}#;_*bZwpu`hu>=JOAF}><`K8sQ5pm<& zSDwt2$ekFu2C1!Afj@GN-ql?`;cv*iXKqGQQ|VAM?r}TS^XqPQhnQE;%b(*7bw796 zUnW|-^0>%VZoTh;6__wz=ll&Bx$-dfAh~<%Vkkd%XMdRoHj(`N-*tTe1^6^zjc5SU z;R6u#9xbR!_-&~L8*HgS^dYF242?iIZ7~@OJ%G!4g6&HISX0`95D*eLG^SPM(oWG{ z#dJWWPVMnULqf7UjhhCy(rJ8Pvy*aqwBSN}Y{jgjhV%5XN@CrErd~@JYpL`#Ayhpy z|Ia*h^$$W}H`1PQSqI6TrWy>J$$t^ARqGq9&9D4!p~~*Fdo^QA*!v(aS89fR(QhGv zD8Vc;$}Z{-L#W)uKtPB`W;}i1mKkHt%zL`g1xb2e2OQ3NwI8x?d89;}ls~1|I=#6P zY$?6vuRWhbb+eP+iXOCW1r80jB0w!5fMEhw35nabT|xJ3YZAMyUSLvqbf`AXIHIxB z$Gvl%ikg-+>edNFJQ0@Y;K1p^E+Ar204xrR@7tph6%;U_~|&P>>@>3s19B$GPdK z)*e0xwh~*qcT{(aN&k4N*nBCUXjZ11KYDI=w6IT0CdZJWp+}Qm|MeHOY~PBl1DYPw z$&#R5vmJxfbi)ssE#_)LYfqkC6GAzKq3eqE?|MiQb@*`T@M{=LJgJ@Xp>0{dUzgt6 z9#>Qb6YNFC+ENeKS<~j0Yut3$y2{v*{+iqg53!*#+s4+?!E2bgTCm?#lT!S%%89Le}7D~crZJ${$x*( z=iuIeKwSK|@^r`H`?H^e z&xJo7H!!M+zNy|SfBztODPtk+^xS|n#wGYwi1uq?hjv4f--J*`P8qZX*vQNRX`#3f zP|qw>HlkIk+C5fEGr|B0ktqf!6-0I`(vV7LHEJ3+0^IU-liyUVSr z$^z1{E84G*1h5MT3r6h~eaHXL4_j)VjSI5YCQzj28h``hT z(oo{r45}Wwv`PBaQxN>e0bX-uiIo z=FL=1f~cSxc4>-DUIah5DIC8KG)vKlroy>_x%{w^1AXv>UE+Zc)-dPe*jdenViD4) zja?s$D8=aL!DGc8mX&wAhwZ*T+mm`0fd4zapX-kEpKCt1ERaOsn>c6~t-klh)yO|& zK+_ENeI-VK?Nz=`BA#n(*TGS}`~BGC?{RY>gnAYXf&AlciN3U-#u8G*T->gPA1a>a z2Ro*;3h}>Mar4axPybB_WfWi!_Jx>sL?v0JKaxTvmE5Ef8s14k2JxUgpd(Gpy@{X3b4Z1O64|y00tN-=`1c2G<(;eZ;C~U#vuu z*Y#e+{1M^D9qQiLF4NO~zbD8xJA1lr%WD2EgAX$>r7k9zWo%~WSBh!qg;WMr+#SD} z)x$o{F^|HEfoGqeE+mi+-GnGRm$|OQP>R*ISl{ogxvaFldKa2{(hxm5T!Px7=R}FN zkC4HnMwH_5_7pHOjZS|?jAo#^+KYJfn+EdtxD&A4Mb;}8(5zX4JSC5!18!PswFrO1 z+Fx|^IA7Cwu=^mW+~Dh@Pur&MUHdS$HHRAlyq^p&mme+%m!IeE&F(o*LVYEJvlinY zUeUTe865gfm~fc*?IJti;^Io~N^xO1|K&L&f0RUc#C~e4GoDW1x=Ob?4PhBb(#U8gr`-;b~YAO5oOABX2qu zf4;%fd+%f~bu+|Lq!HlfWtl<;C1GjHPz|CPb2?xmv?GaB&bBQx-5kubzHAe(YCpMM zll#!Y6TN=WsH~MLnv9aJ@^*&0*s-av_`En{QP|T8^5+^+3sBonXV^u~w1#dxQu%UQ z6MZkP26TmHElUFA+`io+)Vb4MkB5yPfD=(~O)>i-VwQ_^W641x{EaPBA~Ew>z3Iev zavEiyALc5C$$t<6RAnv!8!$GI++ziN4|@!S40TJXO3X_h3|T^Nf`frE*&6^Ly8*ok zz~~O3-!*{}ehg4G#^q=+wM^sD=d!^T$(kQZb;e^j%iRg5_>!%vmJtN8?B*&lvzoHr zd~QkSVsa+@?F0`P2$*p>b0QU<%Gv8*6GDypl%Sl#A8s4>uRQdtFUL|eJiV$mVv+x*tXsIMrG)GaEHIl)Vf6-MI9PD;D zs1&gLvuSf-Y_l<-F^K+}_{vh-Z30nM{q=?DP)*X|;<#re#$bP7HHigFVur*Ng_`6~~R z{v!_w5o;AiB%4;MX^c;@T01!0FK|g!lQgU^O(KVmX|x4=JWyCR^MQuBzZl@Jy z*y48hoOMY_Lmtb_Kt<9*l8OV0rLUxQXB4`-8J87Cb6R_sWOH)D(D5MsX2d4C!&{p; zF#HA*w8~3>MWTfyEXa#v*4fZ_Tl*L}fAk*7%9KwOYavXB@qYMmbnCc-C=M$Zz{L0@ zT>3S#gxIHuHiSE&5L$FgZf9YcH$H)TO(Q+(vvr*Agk!sBquJZ0+3|$PwwFR%Hrcce z<_U$<7wJJaN2jh#xQyr1YyD_*$2)|EUh2nn*G}j=S-%uo9-@TZF_duoN_is@?N1H; zM?(MU=Q0F%o~&_%1)NBVBvFD}a5oL@dR-ziJO?6-dIbVaxH6LNkX{4hg$&YGi=Z|E z8NjDOj-GPbn>QD0QSExVvh_Y;WBFx5XA-pkeo-n0)xI$egJBVZ&^*+D5y zu4^uKSv6d-Jj4MHGc#v#OIJI38=9vCCnLwt$2yrTUAN7?&E(l(c)eoLK35gywc?$@~3SJAW_yp5RqW-B+o9b zuxh8cIN8-G6a;JX`pc6R7V)U0X;p3CE8?p;+HjiS{iq&j7El$v5&C+dOX&wBsoK{c*BW}cSE{j+#~5Z%JsFMDOTH$w6@MD-g24ZKD43iu{ZB%; zZKQSD7PQ+pu42++%Z3b`tJ&VY6>H>zV9q&4lg&mfb3Le>tbSO?xSR8)#xjoA>Pd&6`l`_{#H>V@g7fwJgS_-%H$fld^A`^n5>DW~gOJRXdUcO4+1$ zB7^7sr5o{6#&ZpCmJvkc zY3?IyBY%cw=QkScMX|lpRr@+P!+$s#kgDL8={M$lW>e3~u;k5F@qBsd^LAcda-Uz$ zEAh*Oa78BbpTV?;kJ4VYxLIr(d=bzK9H2a~P#qIre-_f-aca4Ed7kT3ZZg(;+#J2? z-SyuPLT&U|1e6iyfD_misP8Ro!n9 z!%t2aAH3Rh-%YXt*NVOAG)vAinz_$ij=S}OQ=2Yj{}Y!~Nzu}DpG!pkKeoZ z{3Tw|1l+r~R(QC$-O?Yp2>Mk!KO9{4qOU33rwe+nXBsn7J7;k6WPU>Qpy==@qkN1! ziHx7x3JhwoVgUPE-3cqRD#X;VewZZW-21@6IXa2OIt4QzNMfY-y(6s+eQkmp*$3)^ zoCELML>Tw14PVpcD0*y5${K)=Jyu>4Q8WSWf}%!^?QhGY7mY2ctb-XtfuB|_*jg*E6>moRx; zCW059x2?5lkQe|90Tx_N14D*P76BGzEjuCEt#bUI1C7L|U)}zpq3P;B2_^b=eXR8L zdklN6u`99noGZ}QKrts;u9<6ncExy*L7_;VJ9s#hpqL|@hmoXeS$`(eKf!KCnoy{9 z*R@Y(_Kr>{83t1^y|p;nhiz9po~sM`%nDaL@#P-p=?zX8raYJcUk|N-q8L+yE0w_W z(Jh_KDfv4uLW&6~c=YBSh$w!Dyp-5Xx3#2(E5eez)|0-qA%kF&>QfGm%?3p)3c(r= z2X5T^>Otx`qpLLy1d>~Fb8yA2x^%D@Y$+<&~b_I(w4EV*X&#mP#}Q3(=$y z$4?yV=%4FzQGc-E6K%Y%>rlDlr*AB6y+bxSTfFetxh9pL`fLt>!219R#0S7A)C5SE zi-YJqSrDnd{3+stoehzZy`-I$8>-Jz!!2qk%-lMP0Of#k_FX$Oye6rk*;(`tZ)a1x zVq{3(7zk7z7ZDVZY~yKpy~D~^Q;nPx zdR_)?*{l?J7ze$$OQI^G*09qW;-dLPctg_lz2;-jJHg*cF+c?1LsBHBHXRcGPzZ^h z#7=;|${kmaS%r+OH$d`E`DjojaUgM%q>+R#1dxPDY)E{75eWtn#bJVI2{1uqap-W& za3;cMpRs#;+BgA%u{;=uM%#3OJratV*r#GOeM~{hPw^ahSHLNNMSwv_xXkj28l*y? zay)Z`G)%X-`H79k+Yea>AtBm7)OkY=S0p7b(lU4kZ;?$Hm8ZY0ICypcldck%XdW9o z?8{Xj;aWtTXx@Xv^w&G*z2yQWl@mMpk8&?XpZxUmkoehVy!Dn!v}-4SE#GhA(dX}- zKTjt-@De6HCzJ17{9KjaC?TG~{J+%PztQHkQTQ2k+TeD5zw4KkySZjYYH$U*K4J}n zgE0Qhw}E7sP!EDv5EtYqK*t~n2dqKFCq+ha01vS(*y;uV2GyO=ANB+LL|zDdQjq^Q zIx-im7Ex+^9(1KJ#5e~N0CwYTQoa=&8jFLlkrAQp*9M0j;YP43j6TD_D5y^(P{Keb zhy`KrYh;mD3x1>K1Yu#J!BseM!umtd;3@Q)jQm-J--J1_;b<3;S%hDpRc$L@#DZ)^0`$k|Zf_^X~GQBwor|3q`Lck-sQW^mQUE#Ety8$lCSjC+aE9lrI*z z;z=i{Ja-bn-f@Rp>5=wqu62gZKYu0^i9{z#|J40&($IfyAO2sxb4{ojj;-mWz#miq jy4Db?U5a|@bO}8O#DpUB7)9v+)$jWMKj;7CFYrGAr&j7d literal 0 HcmV?d00001 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/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