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 new file mode 100644 index 0000000..d0207af --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# 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). + +# 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. 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. +# Comma-separated list, checked in order. +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 ea8c4bf..1c84ff0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,23 @@ /target +.env +.env.* +!.env.example +*.local + +# Library working files (runtime-generated assets) +library/.tmp/ +library/output/ + +# 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/Cargo.lock b/Cargo.lock index cb03688..339c7de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,24 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -38,7 +56,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,14 +76,92 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "capcut-cli" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "clap", + "regex", + "reqwest", + "scraper", "serde", "serde_json", + "thiserror", + "uuid", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -115,138 +211,2086 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "heck" -version = "0.5.0" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] -name = "itoa" -version = "1.0.18" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "memchr" -version = "2.8.0" +name = "cssparser" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "cssparser-macros" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "derive_more" +version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "quote" -version = "1.0.45" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", + "quote", + "syn", ] [[package]] -name = "serde" -version = "1.0.228" +name = "dtoa" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" dependencies = [ - "serde_core", - "serde_derive", + "dtoa", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "ego-tree" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "serde_derive", + "cfg-if", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "proc-macro2", - "quote", - "syn", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "foreign-types-shared", ] [[package]] -name = "strsim" -version = "0.11.1" +name = "foreign-types-shared" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] -name = "syn" -version = "2.0.117" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "percent-encoding", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "futf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] [[package]] -name = "utf8parse" -version = "0.2.2" +name = "futures-channel" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] [[package]] -name = "windows-link" +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] [[package]] -name = "windows-sys" -version = "0.61.2" +name = "getopts" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "windows-link", + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15" +dependencies = [ + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4ad8b4b..db0ea30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,12 @@ edition = "2024" [dependencies] anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } +regex = "1" +reqwest = { version = "0.12", features = ["blocking", "json"] } +scraper = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" +thiserror = "2" +uuid = { version = "1", features = ["v4"] } 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 04f6894..1b53da7 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,357 @@ # capcut-cli -An open source, agent-first video editing CLI for generating short 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. -## What this is +## Status -`capcut-cli` is a Rust project for agents that need to assemble short-form videos programmatically. +The honest minimum viable truth is **fresh input in, finished clip out.** -The goal is not to recreate a full nonlinear editor. The goal is to expose the primitives an agent actually needs: +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. -- discover and collect candidate media -- ingest audio and video assets into a local library -- trim and normalize clips -- align visuals to audio -- compose short videos from reusable pipelines -- export social-ready outputs for surfaces like Twitter/X -- operate entirely from a command line interface +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. -## Project goals +What's solid today: -This project starts from four immediate requirements: +- 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 -1. Research how to pull trending sounds from TikTok programmatically -2. Research how to pull viral video clips from Twitter/X -3. Build a prototype that combines trending audio with relevant video into short clips suitable for posting on Twitter/X -4. Package the whole thing as an agent-first CLI +## Quick start -## Design principles +Build and verify dependencies: -### Agent-first +```bash +cargo build --release +./target/release/capcut-cli deps check -Every important action should be scriptable, composable, and inspectable. +# If yt-dlp is missing, install the standalone binary into ~/.capcut-cli/bin +./target/release/capcut-cli deps install +``` -That means: +Run the primary flow — import one sound URL plus one or more clip URLs, then +compose: -- stable CLI commands -- machine-readable JSON output where useful -- predictable file layouts -- explicit inputs and outputs -- no GUI dependency -- no hidden timeline state +```bash +# 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 +``` -### Library-backed +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`). -Part of this repository will become a large library of sounds and clips. +A batch script that wraps the above for three clips at once is documented +below under **Batch: three finished clips**. -The CLI should eventually manage: +## Requirements -- metadata for downloaded and curated sounds -- metadata for source clips -- tags, themes, and semantic relevance -- deduplication -- provenance tracking -- prepared intermediates for fast recomposition +- 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) -### Rust core +On macOS, `brew install ffmpeg` is the simplest way to satisfy ffmpeg. -Rust is the implementation language for reliability, portability, and strong CLI ergonomics. +## Batch: three finished clips -Likely building blocks include: +`scripts/build-clips-from-urls.sh` takes one supplied sound URL plus three +supplied clip URLs and produces a self-contained `clips/` folder: -- `clap` for CLI structure -- `serde` and `serde_json` for config and machine-readable output -- `tokio` for async network and pipeline orchestration -- `reqwest` for HTTP/API access -- `ffmpeg` invoked as a system dependency for actual media transforms +- `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) -## Proposed shape +Local invocation: -### Commands +```bash +SOUND_URL="https://..." \ +CLIP_URLS="https://url1 https://url2 https://url3" \ + ./scripts/build-clips-from-urls.sh +``` -Possible early command surface: +### Path A — GitHub Actions (phone-friendly) -- `capcut-cli research tiktok-sounds` -- `capcut-cli research twitter-clips` -- `capcut-cli library import-sound` -- `capcut-cli library import-clip` -- `capcut-cli compose short` -- `capcut-cli export twitter` +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. -### Repository layout +### Path B — Codespaces -Possible initial layout: +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. -- `src/cli/` for command definitions -- `src/research/` for source-specific acquisition logic -- `src/library/` for asset registry and metadata -- `src/media/` for ffmpeg pipeline generation -- `src/compose/` for clip assembly logic -- `library/` for local asset manifests and indexes -- `notes/` for ongoing research findings +## Commands -## Immediate next steps +### `deps` -- put up this README -- research the acquisition paths for TikTok sounds and Twitter/X clips -- map the legal and technical constraints around each source -- sketch the MVP architecture -- build the first committed sound library deliverable -- post progress updates as the work becomes concrete +Manage runtime dependencies. -## First deliverable +```bash +cargo run --release -- deps check +cargo run --release -- deps install +``` -The first concrete deliverable is a committed library of popular TikTok sounds, plus a pipeline for adding more over time. +`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. -That means: +### `library` -- committed sound metadata in the repo -- committed sample audio files for preview and feedback -- a documented acquisition pipeline -- CLI primitives that will eventually automate discovery and refresh +Manage local media assets stored under `library/`. -## Status +```bash +# Import from a supported URL +./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 +./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 +./target/release/capcut-cli library delete snd_demo001 +``` + +Import behavior: + +- `--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 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 detected by the downloader: + +- TikTok +- X/Twitter +- YouTube +- Instagram + +### `compose` + +Render one final MP4 from one sound plus one or more clips. + +```bash +./target/release/capcut-cli compose \ + --sound snd_demo001 \ + --clip clp_demo001 \ + --duration 20 \ + --resolution 1080x1920 \ + --loudness viral +``` + +Options: + +- `--sound `: required sound asset ID +- `--clip `: required, repeatable clip asset ID +- `--duration `: output duration, default `30` +- `--output `: optional explicit output path +- `--resolution `: default `1080x1920` +- `--loudness `: preset or numeric LUFS value + +Built-in loudness presets: + +- `viral`: `-8 LUFS` +- `social`: `-10 LUFS` +- `podcast`: `-14 LUFS` +- `broadcast`: `-23 LUFS` + +Compose pipeline: + +1. normalize the chosen sound with `ffmpeg` loudness normalization +2. trim audio to the requested duration +3. trim each clip to its segment duration +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`. + +## 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 +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. + +Example: + +```json +{ + "status": "ok", + "command": "library list", + "data": { + "count": 2, + "assets": [] + }, + "errors": [], + "meta": { + "version": "0.1.0", + "duration_ms": 2 + } +} +``` + +Behavior guarantees: + +- stdout is machine-readable JSON +- stderr is for human-readable progress messages +- success exits with code `0` +- `deps check` exits with code `2` when dependencies are missing +- 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 +src/ + cli.rs # clap command tree and dispatch + config.rs # paths, version, loudness presets + deps.rs # ffmpeg checks and yt-dlp installation + discover/ + 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 + downloader.rs # yt-dlp integration + ffmpeg.rs # ffmpeg wrappers + models.rs # asset and compose result models + output.rs # JSON envelope helpers +library/ + manifest.json # imported asset index used by the CLI + 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 + +`library/manifest.json` references two small committed fixtures so `compose` +works immediately on a freshly cloned repo: -Day one, but no longer just a placeholder. +- `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` -Current state: +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. -- README and initial research notes are in place -- first Rust CLI scaffold exists -- commands now emit structured JSON for discovery, library planning, and composition planning -- next step is wiring real source adapters and ffmpeg-backed rendering +## Testing -## Current CLI surface +Run the full Rust test suite with: ```bash -capcut-cli discover tiktok-sounds --limit 10 -capcut-cli discover x-clips --query "ai agents" --limit 10 -capcut-cli library sound --from --id -capcut-cli library clip --from --id -capcut-cli compose --sound sound_123 --clip clip_a --clip clip_b --duration-seconds 30 +cargo test --all-targets ``` -Each command currently returns machine-readable JSON so an agent can inspect the plan before the implementation becomes fully operational. +Coverage currently includes: + +- X clip scoring and guided-fallback labeling +- TikTok `import_url` normalization +- downloader error classification for X auth/media failures +- import metadata enrichment for TikTok embeds +- loudness preset resolution +- numeric loudness parsing +- duration parsing in the ffmpeg helpers +- 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 `test` GitHub Actions workflow runs `cargo test --all-targets` on every +push. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..726cbf5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security + +This repo can work with real credentials, so treat local development runs as sensitive. + +## What the CLI does + +- reads `TWITTER_BEARER_TOKEN` from the environment at runtime +- reads `TIKTOK_RESEARCH_ACCESS_TOKEN` from the environment at runtime +- uses `yt-dlp --cookies-from-browser` for authenticated X/Twitter media retrieval +- redacts token-like query parameters and signed URL fragments from logs +- strips token-like query parameters before persisting imported asset source URLs + +## What you should do + +- use a dedicated low-scope X API token for this tool +- use a dedicated low-scope TikTok Research API token for this tool +- keep browser-cookie auth only on a trusted machine +- avoid pasting cookie values into files or commands when `--cookies-from-browser` is available +- do not share shell history, raw stderr logs, or screenshots from authenticated sessions +- rotate or revoke tokens if you suspect they were exposed + +## Files to keep local + +The repo ignores common local secret files: + +- `.env` +- `.env.*` +- `*.local` + +If you need environment variables, keep them in a local file that is not committed. + +## Reporting issues + +If you find a place where a token, cookie, signed URL, or other credential is being persisted or logged in clear text, treat it as a bug and fix it before using the repo with real credentials again. diff --git a/library/.DS_Store b/library/.DS_Store new file mode 100644 index 0000000..9d660d5 Binary files /dev/null and b/library/.DS_Store differ 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/notes/implementation-research-2026-04-12.md b/notes/implementation-research-2026-04-12.md new file mode 100644 index 0000000..90438e6 --- /dev/null +++ b/notes/implementation-research-2026-04-12.md @@ -0,0 +1,122 @@ +# Implementation research - 2026-04-12 + +## Goal + +Document the repo's actual acquisition and rendering strategy for the "strong yes" path: + +- discover trending TikTok sounds programmatically +- discover viral X/Twitter clips programmatically +- import both assets without manual timeline work +- compose a Twitter-postable short in the CLI + +## TikTok sound acquisition + +### Surface used + +The repo uses TikTok Creative Center pages, not an official public TikTok API for trending sounds. + +Current path: + +1. try the Creative Center JSON endpoint +2. fall back to Creative Center HTML crawling +3. crawl song detail pages +4. normalize each result into a stable JSON shape + +### Why this is unofficial + +There is no stable official TikTok developer API in this repo for "trending sounds" the way we need it. +Creative Center is a public web surface and can change without notice. + +### Import fallback chain + +For each discovered sound: + +- `tiktok_url` is the canonical/reference music page +- `import_url` is the URL the CLI should actually ingest + +Preferred `import_url` order: + +1. direct preview audio URL when available +2. related TikTok embed URL from the song detail payload +3. canonical TikTok music URL as last resort + +This is intentional because direct TikTok music-page downloads are currently less reliable than related embed imports through `yt-dlp`. + +## X/Twitter clip discovery + +### Surface used + +Discovery uses the official X recent search API. + +Current path: + +1. require `TWITTER_BEARER_TOKEN` +2. search for query + `has:videos -is:retweet` +3. expand author and media metadata +4. filter to tweets with video or animated GIF media +5. rank deterministically by engagement + recency + +### Why discovery and import are split + +Official API search is good for finding and ranking posts. +It is not the same thing as obtaining a downloadable media asset. + +So the repo intentionally splits X handling into: + +- official API for discovery and ranking +- authenticated `yt-dlp` retrieval for media import + +## Downloader and auth assumptions + +### X/Twitter + +Reliable X import is treated as authenticated by default. + +The downloader: + +- tries `--cookies-from-browser` +- uses `CAPCUT_X_COOKIE_BROWSERS` if set +- otherwise tries `chrome,safari,firefox,edge` + +Structured failure cases are intentionally separated: + +- auth required +- rate limited +- suspended tweet +- no downloadable video +- unavailable video + +### TikTok + +TikTok imports currently rely on `yt-dlp` plus Creative Center-derived `import_url` values. +The fallback chain is important because the canonical music pages are not always directly downloadable. + +## ffmpeg composition pipeline + +The render path is: + +1. normalize audio loudness +2. trim audio to target duration +3. trim each clip to its segment duration +4. scale and center-crop to target resolution +5. concatenate clips +6. mux H.264 video with AAC audio + +Default target format is suitable for Twitter/X posting: + +- vertical `1080x1920` by default +- H.264 video +- AAC audio +- MP4 container + +## Strong-yes acceptance path + +The repo should be judged against this exact flow: + +1. `deps check` passes +2. TikTok discovery returns at least one result with a non-empty `import_url` +3. X discovery returns ranked live clip candidates when `TWITTER_BEARER_TOKEN` is configured +4. TikTok sound import succeeds from `import_url` +5. X clip import succeeds with browser-cookie auth +6. compose succeeds on those freshly imported assets +7. `ffprobe` confirms H.264 + AAC output 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/cli.rs b/src/cli.rs index 8b84f42..4081480 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,10 +1,8 @@ use anyhow::Result; -use clap::{Args, Parser, Subcommand, ValueEnum}; +use clap::{Args, Parser, Subcommand}; +use std::time::Instant; -use crate::models::{ - AppReport, DiscoverSource, DiscoveryReport, LibraryReport, MediaReport, PipelineStep, - PipelineStepKind, -}; +use crate::{config, deps, discover, library, media, output}; #[derive(Debug, Parser)] #[command( @@ -17,181 +15,907 @@ pub struct Cli { command: Command, } +#[derive(Debug, Subcommand)] +enum Command { + /// Manage dependencies (yt-dlp, ffmpeg). + Deps(DepsArgs), + /// Discover trending sounds and viral clips. + Discover(DiscoverArgs), + /// Manage the local asset library. + Library(LibraryArgs), + /// Compose clips with a sound into a final video. + Compose(ComposeArgs), + /// One-shot agent workflow: discover, import, and compose automatically. + Autopilot(AutoPilotArgs), +} + impl Cli { pub fn run(self) -> Result<()> { - let report = match self.command { + match self.command { + Command::Deps(args) => args.run(), Command::Discover(args) => args.run(), Command::Library(args) => args.run(), Command::Compose(args) => args.run(), - }?; + Command::Autopilot(args) => args.run(), + } + } +} + +// ── deps ──────────────────────────────────────────────────────────── + +#[derive(Debug, Args)] +struct DepsArgs { + #[command(subcommand)] + action: DepsAction, +} + +#[derive(Debug, Subcommand)] +enum DepsAction { + /// Check if all dependencies are installed. + Check, + /// Download and install all dependencies. + Install, +} + +impl DepsArgs { + fn run(self) -> Result<()> { + match self.action { + DepsAction::Check => { + let t = Instant::now(); + let result = deps::check_all(); + let all_ok = result + .as_object() + .map(|m| { + m.values() + .all(|v| v.get("installed").and_then(|i| i.as_bool()).unwrap_or(false)) + }) + .unwrap_or(false); - println!("{}", serde_json::to_string_pretty(&report)?); + if all_ok { + output::emit(&output::success("deps check", result, Some(t))); + } else { + let mut env = output::error( + "deps check", + "MISSING_DEPS", + "Some dependencies are not installed.", + Some("Run 'capcut-cli deps install' to install them."), + ); + env.data = result; + output::emit(&env); + std::process::exit(2); + } + } + DepsAction::Install => { + let t = Instant::now(); + config::ensure_dirs(); + output::log("Installing dependencies..."); + match deps::install_all() { + Ok(result) => { + output::emit(&output::success("deps install", result, Some(t))); + } + Err(e) => { + output::emit(&output::error( + "deps install", + "INSTALL_FAILED", + &e.to_string(), + None, + )); + std::process::exit(1); + } + } + } + } Ok(()) } } +// ── discover ──────────────────────────────────────────────────────── + +#[derive(Debug, Args)] +struct DiscoverArgs { + #[command(subcommand)] + action: DiscoverAction, +} + #[derive(Debug, Subcommand)] -enum Command { - Discover(DiscoverArgs), - Library(LibraryArgs), - Compose(ComposeArgs), +enum DiscoverAction { + /// Find currently trending TikTok sounds. + #[command(name = "tiktok-sounds")] + TiktokSounds { + /// Max results to return. + #[arg(long, default_value_t = 10)] + limit: u32, + /// Region code. + #[arg(long, default_value = "US")] + region: String, + /// Rolling discovery window in days. + #[arg(long = "window-days", default_value_t = 7, value_parser = clap::value_parser!(u32).range(1..))] + window_days: u32, + /// Sound discovery strategy: auto, research, creative-center, library, manual-url. + #[arg(long, default_value = "auto")] + strategy: String, + /// Manual sound URL used when strategy is `manual-url`, or as an `auto` fallback. + #[arg(long = "sound-url")] + sound_url: Option, + }, + /// Find viral video clips on X/Twitter. + #[command(name = "x-clips")] + XClips { + /// Search query for viral clips. + #[arg(long)] + query: String, + /// Max results. + #[arg(long, default_value_t = 10)] + limit: u32, + /// Minimum likes filter. + #[arg(long, default_value_t = 1000)] + min_likes: u64, + /// Clip discovery strategy: auto, api, guided, library, manual-url. + #[arg(long, default_value = "auto")] + strategy: String, + /// Manual X clip URL used when strategy is `manual-url`, or as an `auto` fallback. + #[arg(long = "clip-url")] + clip_url: Option, + }, +} + +impl DiscoverArgs { + fn run(self) -> Result<()> { + match self.action { + DiscoverAction::TiktokSounds { + limit, + region, + window_days, + strategy, + sound_url, + } => { + let t = Instant::now(); + let strategy = match discover::tiktok::SoundDiscoveryStrategy::parse(&strategy) { + Ok(value) => value, + Err(error) => { + output::emit(&output::error( + "discover tiktok-sounds", + "INVALID_STRATEGY", + &error.to_string(), + None, + )); + std::process::exit(1); + } + }; + let options = discover::tiktok::SoundDiscoveryOptions { + limit, + region: region.clone(), + window_days, + strategy, + manual_url: sound_url, + }; + match discover::tiktok::find_trending_sounds_with_options(&options) { + Ok(data) => { + output::emit(&output::success("discover tiktok-sounds", data, Some(t))); + } + Err(e) => { + output::emit(&output::error( + "discover tiktok-sounds", + "DISCOVERY_FAILED", + &e.to_string(), + Some( + "Set TIKTOK_RESEARCH_ACCESS_TOKEN for official discovery. If the fallback scraper is failing, try again later or import a sound manually with 'capcut-cli library import --type sound'.", + ), + )); + std::process::exit(1); + } + } + } + DiscoverAction::XClips { + query, + limit, + min_likes, + strategy, + clip_url, + } => { + let t = Instant::now(); + let strategy = match discover::twitter::ClipDiscoveryStrategy::parse(&strategy) { + Ok(value) => value, + Err(error) => { + output::emit(&output::error( + "discover x-clips", + "INVALID_STRATEGY", + &error.to_string(), + None, + )); + std::process::exit(1); + } + }; + let options = discover::twitter::ClipDiscoveryOptions { + query, + limit, + min_likes, + strategy, + manual_url: clip_url, + }; + match discover::twitter::find_viral_clips_with_options(&options) { + Ok(data) => { + output::emit(&output::success("discover x-clips", data, Some(t))); + } + Err(e) => { + let (code, hint) = match e + .downcast_ref::() + { + Some(discover::twitter::TwitterDiscoveryError::AuthRequired) => ( + "X_AUTH_REQUIRED", + Some( + "Set TWITTER_BEARER_TOKEN for official X discovery, or pass \ + --allow-guided-fallback to get browser search URLs instead.", + ), + ), + Some(discover::twitter::TwitterDiscoveryError::RateLimited) => ( + "X_RATE_LIMITED", + Some("Retry later or reduce request frequency."), + ), + Some(discover::twitter::TwitterDiscoveryError::ApiRequest { .. }) => ( + "X_API_REQUEST_FAILED", + Some("Verify network access and your TWITTER_BEARER_TOKEN."), + ), + Some(discover::twitter::TwitterDiscoveryError::ApiStatus { .. }) => ( + "X_API_STATUS_ERROR", + Some("Verify your TWITTER_BEARER_TOKEN and X API access tier."), + ), + None => ("DISCOVERY_FAILED", None), + }; + output::emit(&output::error( + "discover x-clips", + code, + &e.to_string(), + hint, + )); + std::process::exit(1); + } + } + } + } + Ok(()) + } } +// ── library ───────────────────────────────────────────────────────── + #[derive(Debug, Args)] -struct DiscoverArgs { - #[arg(value_enum)] - source: DiscoverSourceArg, +struct LibraryArgs { + #[command(subcommand)] + action: LibraryAction, +} + +#[derive(Debug, Subcommand)] +enum LibraryAction { + /// Download a sound or clip from a URL into the library. + Import { + /// URL to import. + url: String, + /// Asset type. Auto-detected from URL if omitted. + #[arg(long = "type")] + asset_type: Option, + /// Comma-separated tags. + #[arg(long, default_value = "")] + tags: String, + }, + /// List all assets in the library. + List { + /// Filter by type. + #[arg(long = "type")] + asset_type: Option, + }, + /// Show details of a specific asset. + Show { + /// Asset ID. + asset_id: String, + }, + /// Remove an asset from the library. + Delete { + /// Asset ID. + asset_id: String, + }, +} + +impl LibraryArgs { + fn run(self) -> Result<()> { + match self.action { + LibraryAction::Import { + url, + asset_type, + tags, + } => { + let t = Instant::now(); + config::ensure_dirs(); + let tag_list: Vec = tags + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + match library::import_asset(&url, asset_type.as_deref(), &tag_list) { + Ok(asset) => { + let data = serde_json::to_value(&asset)?; + output::emit(&output::success("library import", data, Some(t))); + } + Err(e) => { + let (code, hint) = + match e.downcast_ref::() { + Some(media::downloader::DownloadError::XAuthRequired { .. }) => ( + "X_AUTH_REQUIRED", + Some( + "Log into X in a supported local browser and rerun the \ + import. Configure CAPCUT_X_COOKIE_BROWSERS if needed.", + ), + ), + Some(media::downloader::DownloadError::XRateLimited) => ( + "X_RATE_LIMITED", + Some("Retry later; X temporarily rate-limited media access."), + ), + Some(media::downloader::DownloadError::XSuspended { .. }) => ( + "X_TWEET_SUSPENDED", + Some("Pick another clip candidate; this tweet is suspended."), + ), + Some(media::downloader::DownloadError::XNoVideo { .. }) => ( + "X_NO_VIDEO", + Some( + "Use a tweet URL that actually contains downloadable video \ + media.", + ), + ), + Some(media::downloader::DownloadError::XVideoUnavailable { .. }) => ( + "X_VIDEO_UNAVAILABLE", + Some("Pick another clip candidate; this video is unavailable."), + ), + Some(media::downloader::DownloadError::AudioConversionFailed { .. }) => ( + "AUDIO_CONVERSION_FAILED", + Some("Verify ffmpeg is installed and supports MP3 encoding."), + ), + Some(media::downloader::DownloadError::YtDlpFailure { .. }) => ( + "IMPORT_FAILED", + Some( + "Run 'capcut-cli deps check' to verify yt-dlp is \ + installed.", + ), + ), + None => ( + "IMPORT_FAILED", + Some( + "Run 'capcut-cli deps check' to verify yt-dlp is \ + installed.", + ), + ), + }; + output::emit(&output::error( + "library import", + code, + &e.to_string(), + hint, + )); + std::process::exit(1); + } + } + } + LibraryAction::List { asset_type } => { + let t = Instant::now(); + let assets = library::list_assets(asset_type.as_deref())?; + let data = serde_json::json!({ + "count": assets.len(), + "assets": assets.iter().map(|a| serde_json::to_value(a).unwrap()).collect::>(), + }); + output::emit(&output::success("library list", data, Some(t))); + } + LibraryAction::Show { asset_id } => { + let t = Instant::now(); + match library::get_asset(&asset_id)? { + Some(asset) => { + let data = serde_json::to_value(&asset)?; + output::emit(&output::success("library show", data, Some(t))); + } + None => { + output::emit(&output::error( + "library show", + "NOT_FOUND", + &format!("Asset '{asset_id}' not found."), + Some("Run 'capcut-cli library list' to see available assets."), + )); + std::process::exit(1); + } + } + } + LibraryAction::Delete { asset_id } => { + let t = Instant::now(); + match library::delete_asset(&asset_id) { + Ok(()) => { + output::emit(&output::success( + "library delete", + serde_json::json!({"deleted": asset_id}), + Some(t), + )); + } + Err(e) => { + output::emit(&output::error( + "library delete", + "DELETE_FAILED", + &e.to_string(), + None, + )); + std::process::exit(1); + } + } + } + } + Ok(()) + } +} + +// ── compose ───────────────────────────────────────────────────────── +#[derive(Debug, Args)] +struct ComposeArgs { + /// Sound asset ID from the library. + #[arg(long)] + sound: String, + + /// Clip asset ID (repeatable). + #[arg(long = "clip", required = true)] + clips: Vec, + + /// Output duration in seconds. + #[arg(long, default_value_t = 30.0)] + duration: f64, + + /// Output file path. Auto-generated if omitted. #[arg(long)] - query: Option, + output: Option, - #[arg(long, default_value_t = 10)] - limit: u32, + /// Output resolution WxH (default: vertical 1080x1920). + #[arg(long, default_value = "1080x1920")] + resolution: String, + + /// Loudness preset or LUFS value. Presets: viral (-8, default), + /// social (-10), podcast (-14), broadcast (-23). Or pass a number like -12. + #[arg(long)] + loudness: Option, } -impl DiscoverArgs { - fn run(self) -> Result { - let (mode, notes, next_steps) = match self.source { - DiscoverSourceArg::TiktokSounds => ( - DiscoverSource::TiktokSounds, - vec![ - "Official TikTok APIs are weak for trending sound discovery".to_string(), - "MVP should use provider adapters, scraper adapters, or import mode".to_string(), - "Keep direct scraping optional because anti-bot measures will change".to_string(), - ], - vec![ - "Add provider adapters with consistent normalized sound metadata".to_string(), - "Support import by sound URL or sound ID for manual seeding".to_string(), - ], - ), - DiscoverSourceArg::XClips => ( - DiscoverSource::XClips, - vec![ - "Prototype discovery via X search plus engagement metrics".to_string(), - "Require attached video media and rank by likes, reposts, replies, quotes, views, and recency".to_string(), - "Media retrieval may still require a separate downloader/import adapter".to_string(), - ], - vec![ - "Add X API credential support and search adapters".to_string(), - "Add downloader abstraction for video asset retrieval".to_string(), - ], - ), - }; +// Make ComposeArgs fields accessible for testing +#[cfg(test)] +impl ComposeArgs { + fn resolution(&self) -> &str { &self.resolution } + fn duration(&self) -> f64 { self.duration } +} - Ok(AppReport::Discovery(DiscoveryReport { - source: mode, - query: self.query, - limit: self.limit, - notes, - next_steps, - })) +impl ComposeArgs { + fn run(self) -> Result<()> { + let t = Instant::now(); + config::ensure_dirs(); + match media::compose::run_compose( + &self.sound, + &self.clips, + self.duration, + self.output.as_deref(), + &self.resolution, + self.loudness.as_deref(), + ) { + Ok(result) => { + let data = serde_json::to_value(&result)?; + output::emit(&output::success("compose", data, Some(t))); + } + Err(e) => { + output::emit(&output::error( + "compose", + "COMPOSE_FAILED", + &e.to_string(), + Some( + "Ensure assets exist with 'capcut-cli library list' and deps are \ + installed with 'capcut-cli deps check'.", + ), + )); + std::process::exit(1); + } + } + Ok(()) } } -#[derive(Clone, Debug, ValueEnum)] -enum DiscoverSourceArg { - #[value(name = "tiktok-sounds")] - TiktokSounds, - #[value(name = "x-clips")] - XClips, -} +// ── autopilot ─────────────────────────────────────────────────────── #[derive(Debug, Args)] -struct LibraryArgs { - #[arg(value_enum)] - asset_type: AssetTypeArg, +struct AutoPilotArgs { + /// Topic/query used to discover relevant X clips. + #[arg(long)] + query: String, + + /// Region code used for TikTok sound discovery. + #[arg(long, default_value = "US")] + region: String, + + /// Rolling window in days for TikTok sound discovery. + #[arg(long = "window-days", default_value_t = 7, value_parser = clap::value_parser!(u32).range(1..))] + window_days: u32, + + /// Number of sound candidates to discover. + #[arg(long = "sound-limit", default_value_t = 5)] + sound_limit: u32, + + /// Number of clip candidates to discover. + #[arg(long = "clip-limit", default_value_t = 5)] + clip_limit: u32, + + /// Minimum likes threshold for clip discovery. + #[arg(long, default_value_t = 1000)] + min_likes: u64, + /// Output duration in seconds. + #[arg(long, default_value_t = 15.0)] + duration: f64, + + /// Output file path. Auto-generated if omitted. #[arg(long)] - from: Option, + output: Option, + + /// Output resolution WxH. + #[arg(long, default_value = "1080x1920")] + resolution: String, + /// Loudness preset or LUFS value. #[arg(long)] - id: Option, + loudness: Option, + + /// Sound discovery strategy: auto, research, creative-center, library, manual-url. + #[arg(long = "sound-strategy", default_value = "auto")] + sound_strategy: String, + + /// Manual sound URL used when sound strategy is `manual-url`, or as an `auto` fallback. + #[arg(long = "sound-url")] + sound_url: Option, + + /// Clip discovery strategy: auto, api, guided, library, manual-url. + #[arg(long = "clip-strategy", default_value = "auto")] + clip_strategy: String, + + /// Manual X clip URL used when clip strategy is `manual-url`, or as an `auto` fallback. + #[arg(long = "clip-url")] + clip_url: Option, } -impl LibraryArgs { - fn run(self) -> Result { - Ok(AppReport::Library(LibraryReport { - asset_type: self.asset_type.as_str().to_string(), - source: self.from, - id: self.id, - required_metadata: match self.asset_type { - AssetTypeArg::Sound => vec![ - "source_url".to_string(), - "platform".to_string(), - "duration_seconds".to_string(), - "creator".to_string(), - "license_or_rights_note".to_string(), - "local_audio_path".to_string(), - ], - AssetTypeArg::Clip => vec![ - "source_url".to_string(), - "platform".to_string(), - "duration_seconds".to_string(), - "topic_tags".to_string(), - "engagement_metrics".to_string(), - "local_video_path".to_string(), - ], +impl AutoPilotArgs { + fn run(self) -> Result<()> { + let t = Instant::now(); + config::ensure_dirs(); + + let sound_strategy = match discover::tiktok::SoundDiscoveryStrategy::parse(&self.sound_strategy) { + Ok(value) => value, + Err(error) => { + output::emit(&output::error( + "autopilot", + "INVALID_SOUND_STRATEGY", + &error.to_string(), + None, + )); + std::process::exit(1); + } + }; + let sound_options = discover::tiktok::SoundDiscoveryOptions { + limit: self.sound_limit, + region: self.region.clone(), + window_days: self.window_days, + strategy: sound_strategy, + manual_url: self.sound_url.clone(), + }; + let clip_strategy = match discover::twitter::ClipDiscoveryStrategy::parse(&self.clip_strategy) { + Ok(value) => value, + Err(error) => { + output::emit(&output::error( + "autopilot", + "INVALID_CLIP_STRATEGY", + &error.to_string(), + None, + )); + std::process::exit(1); + } + }; + let clip_options = discover::twitter::ClipDiscoveryOptions { + query: self.query.clone(), + limit: self.clip_limit, + min_likes: self.min_likes, + strategy: clip_strategy, + manual_url: self.clip_url.clone(), + }; + + let sound_discovery = match discover::tiktok::find_trending_sounds_with_options(&sound_options) { + Ok(data) => data, + Err(error) => { + output::emit(&output::error( + "autopilot", + "SOUND_DISCOVERY_FAILED", + &error.to_string(), + Some("Set TIKTOK_RESEARCH_ACCESS_TOKEN or retry later."), + )); + std::process::exit(1); + } + }; + let clip_discovery = + match discover::twitter::find_viral_clips_with_options(&clip_options) { + Ok(data) => data, + Err(error) => { + let hint = if error + .downcast_ref::() + .is_some() + { + Some("Set TWITTER_BEARER_TOKEN for official X discovery.") + } else { + None + }; + output::emit(&output::error( + "autopilot", + "CLIP_DISCOVERY_FAILED", + &error.to_string(), + hint, + )); + std::process::exit(1); + } + }; + + let sound_candidates = extract_candidates(&sound_discovery, "sounds"); + if sound_candidates.is_empty() { + output::emit(&output::error( + "autopilot", + "NO_SOUND_CANDIDATES", + "No TikTok sound candidates were returned by discovery.", + Some("Set TIKTOK_RESEARCH_ACCESS_TOKEN or retry later when Creative Center is available."), + )); + std::process::exit(1); + } + + let clip_candidates = extract_candidates(&clip_discovery, "clips"); + if clip_candidates.is_empty() { + output::emit(&output::error( + "autopilot", + "NO_CLIP_CANDIDATES", + "No X/Twitter clip candidates were returned by discovery.", + Some("Set TWITTER_BEARER_TOKEN and retry clip discovery."), + )); + std::process::exit(1); + } + + let sound_tags = vec![ + "auto".to_string(), + "workflow".to_string(), + "tiktok".to_string(), + "trending".to_string(), + ]; + let clip_tags = vec![ + "auto".to_string(), + "workflow".to_string(), + "x".to_string(), + "viral".to_string(), + ]; + + let (sound_asset, sound_source, sound_failures) = + match import_first_success(&sound_candidates, "sound", &sound_tags) { + Ok(result) => result, + Err(error) => { + output::emit(&output::error( + "autopilot", + "SOUND_IMPORT_FAILED", + &error.to_string(), + Some("No discovered sound candidate could be imported."), + )); + std::process::exit(1); + } + }; + let (clip_asset, clip_source, clip_failures) = + match import_first_success(&clip_candidates, "clip", &clip_tags) { + Ok(result) => result, + Err(error) => { + output::emit(&output::error( + "autopilot", + "CLIP_IMPORT_FAILED", + &error.to_string(), + Some("No discovered clip candidate could be imported."), + )); + std::process::exit(1); + } + }; + + let composed = match media::compose::run_compose( + &sound_asset.id, + &[clip_asset.id.clone()], + self.duration, + self.output.as_deref(), + &self.resolution, + self.loudness.as_deref(), + ) { + Ok(result) => result, + Err(error) => { + output::emit(&output::error( + "autopilot", + "COMPOSE_FAILED", + &error.to_string(), + Some("Discovery and import succeeded, but compose failed."), + )); + std::process::exit(1); + } + }; + + let data = serde_json::json!({ + "workflow": "autopilot", + "query": self.query, + "region": self.region, + "window_days": self.window_days, + "sound_strategy": self.sound_strategy, + "clip_strategy": self.clip_strategy, + "selected": { + "sound_source_url": sound_source, + "clip_source_url": clip_source, + "sound_asset_id": sound_asset.id, + "clip_asset_id": clip_asset.id, }, - })) + "attempts": { + "sound_candidates_considered": sound_candidates.len(), + "clip_candidates_considered": clip_candidates.len(), + "sound_import_failures": sound_failures, + "clip_import_failures": clip_failures, + }, + "compose": serde_json::to_value(composed)?, + }); + + output::emit(&output::success("autopilot", data, Some(t))); + Ok(()) } } -#[derive(Clone, Debug, ValueEnum)] -enum AssetTypeArg { - Sound, - Clip, +fn extract_candidates(data: &serde_json::Value, key: &str) -> Vec { + data.get(key) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() +} + +fn candidate_import_url(candidate: &serde_json::Value) -> Option { + candidate + .get("import_url") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn candidate_asset_id(candidate: &serde_json::Value) -> Option { + candidate + .get("asset_id") + .and_then(|v| v.as_str()) + .or_else(|| candidate.get("music_id").and_then(|v| v.as_str())) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) } -impl AssetTypeArg { - fn as_str(&self) -> &'static str { - match self { - AssetTypeArg::Sound => "sound", - AssetTypeArg::Clip => "clip", +fn import_first_success( + candidates: &[serde_json::Value], + asset_type: &str, + tags: &[String], +) -> Result<(crate::models::Asset, String, Vec)> { + let mut failures = Vec::new(); + + for candidate in candidates { + if candidate.get("source_path").and_then(|v| v.as_str()) == Some("library") { + if let Some(asset_id) = candidate_asset_id(candidate) { + if let Some(asset) = library::get_asset(&asset_id)? { + return Ok((asset, asset_id, failures)); + } + failures.push(serde_json::json!({ + "asset_id": asset_id, + "error": "candidate referenced library asset that no longer exists" + })); + continue; + } + } + + let Some(url) = candidate_import_url(candidate) else { + failures.push(serde_json::json!({ + "reason": "candidate_missing_import_url" + })); + continue; + }; + + match library::import_asset(&url, Some(asset_type), tags) { + Ok(asset) => return Ok((asset, url, failures)), + Err(err) => { + failures.push(serde_json::json!({ + "import_url": url, + "error": err.to_string(), + })); + } } } + + let err = if asset_type == "sound" { + anyhow::anyhow!("Autopilot could not import any discovered sound candidate.") + } else { + anyhow::anyhow!("Autopilot could not import any discovered clip candidate.") + }; + Err(err) } -#[derive(Debug, Args)] -struct ComposeArgs { - #[arg(long)] - sound: String, +#[cfg(test)] +mod tests { + use super::*; - #[arg(long = "clip", required = true)] - clips: Vec, + use crate::library; - #[arg(long, default_value_t = 30)] - duration_seconds: u32, -} + #[test] + fn test_extract_candidates_reads_array_field() { + let payload = serde_json::json!({ + "sounds": [ + { "import_url": "https://example.com/a" }, + { "import_url": "https://example.com/b" } + ] + }); -impl ComposeArgs { - fn run(self) -> Result { - Ok(AppReport::Media(MediaReport { - sound_id: self.sound, - clip_ids: self.clips, - duration_seconds: self.duration_seconds, - pipeline: vec![ - PipelineStep { - kind: PipelineStepKind::NormalizeAudio, - description: "Normalize imported sound to a consistent loudness target" - .to_string(), - }, - PipelineStep { - kind: PipelineStepKind::TrimClips, - description: "Trim or subclip candidate visuals to fit target duration" - .to_string(), - }, - PipelineStep { - kind: PipelineStepKind::ScaleAndCrop, - description: "Scale and crop footage into target social aspect ratio" - .to_string(), - }, - PipelineStep { - kind: PipelineStepKind::Mux, - description: - "Mux selected visuals with normalized audio into the final short clip" - .to_string(), - }, - ], - })) + let candidates = extract_candidates(&payload, "sounds"); + assert_eq!(candidates.len(), 2); + } + + #[test] + fn test_candidate_import_url_skips_blank_values() { + let blank = serde_json::json!({ "import_url": " " }); + let valid = serde_json::json!({ "import_url": "https://example.com/sound" }); + + assert!(candidate_import_url(&blank).is_none()); + assert_eq!( + candidate_import_url(&valid).as_deref(), + Some("https://example.com/sound") + ); + } + + #[test] + fn test_candidate_asset_id_prefers_asset_id_then_music_id() { + let asset = serde_json::json!({ + "asset_id": "clp_123", + "music_id": "snd_456" + }); + let music = serde_json::json!({ + "music_id": "snd_456" + }); + + assert_eq!(candidate_asset_id(&asset).as_deref(), Some("clp_123")); + assert_eq!(candidate_asset_id(&music).as_deref(), Some("snd_456")); + } + + #[test] + fn test_import_first_success_reuses_existing_library_asset() { + let existing_asset = library::list_assets(Some("sound")) + .unwrap() + .into_iter() + .next() + .expect("expected at least one sound asset in test library"); + let candidates = vec![serde_json::json!({ + "source_path": "library", + "asset_id": existing_asset.id, + "import_url": "https://example.com/should-not-be-used" + })]; + + let (asset, source, failures) = + import_first_success(&candidates, "sound", &["auto".to_string()]).unwrap(); + + assert_eq!(asset.id, existing_asset.id); + assert_eq!(source, existing_asset.id); + assert!(failures.is_empty()); + } + + #[test] + fn test_import_first_success_records_missing_library_asset_failure() { + let candidates = vec![serde_json::json!({ + "source_path": "library", + "asset_id": "snd_missing" + })]; + + let error = import_first_success(&candidates, "sound", &["auto".to_string()]) + .expect_err("missing library asset should fail"); + + assert!( + error + .to_string() + .contains("Autopilot could not import any discovered sound candidate.") + ); } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..163e08a --- /dev/null +++ b/src/config.rs @@ -0,0 +1,88 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::LazyLock; + +pub const VERSION: &str = "0.1.0"; + +/// Get the repository root (parent of the binary's directory, or CWD). +pub fn repo_root() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +pub fn library_dir() -> PathBuf { + repo_root().join("library") +} +pub fn sounds_dir() -> PathBuf { + library_dir().join("sounds").join("assets") +} +pub fn clips_dir() -> PathBuf { + library_dir().join("clips") +} +pub fn output_dir() -> PathBuf { + library_dir().join("output") +} +pub fn tmp_dir() -> PathBuf { + library_dir().join(".tmp") +} +pub fn manifest_path() -> PathBuf { + library_dir().join("manifest.json") +} + +pub fn capcut_home() -> PathBuf { + dirs_home().join(".capcut-cli") +} +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") +} + +fn dirs_home() -> PathBuf { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Create all required directories. +pub fn ensure_dirs() { + for d in &[sounds_dir(), clips_dir(), output_dir(), tmp_dir(), bin_dir()] { + let _ = std::fs::create_dir_all(d); + } +} + +// ── Loudness presets ──────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct LoudnessPreset { + pub lufs: f64, + pub tp: f64, + pub lra: f64, + pub label: &'static str, +} + +pub const DEFAULT_LOUDNESS: &str = "viral"; + +pub static LOUDNESS_PRESETS: LazyLock> = LazyLock::new(|| { + let mut m = HashMap::new(); + m.insert("viral", LoudnessPreset { + lufs: -8.0, tp: -1.0, lra: 7.0, + label: "Social/viral — loud, punchy, cuts through feed scroll", + }); + m.insert("social", LoudnessPreset { + lufs: -10.0, tp: -1.0, lra: 9.0, + label: "General social media", + }); + m.insert("podcast", LoudnessPreset { + lufs: -14.0, tp: -1.5, lra: 11.0, + label: "Podcast / spoken word (Apple, Spotify spec)", + }); + m.insert("broadcast", LoudnessPreset { + lufs: -23.0, tp: -1.0, lra: 15.0, + label: "EBU R128 broadcast standard", + }); + m +}); diff --git a/src/deps.rs b/src/deps.rs new file mode 100644 index 0000000..42c9ddc --- /dev/null +++ b/src/deps.rs @@ -0,0 +1,142 @@ +use anyhow::{Context, Result, bail}; +use serde_json::json; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::Command; + +use crate::config::{bin_dir, ytdlp_path}; +use crate::output; + +/// Find ffmpeg on the system. Checks PATH first, then ~/.capcut-cli/bin/. +pub fn get_ffmpeg_path() -> Result { + // Check PATH + if let Ok(out) = Command::new("ffmpeg").arg("-version").output() { + if out.status.success() { + return Ok("ffmpeg".to_string()); + } + } + // Check bin dir + let local = bin_dir().join("ffmpeg"); + if local.exists() { + return Ok(local.to_string_lossy().to_string()); + } + bail!( + "ffmpeg not found. Install it via your package manager:\n \ + macOS: brew install ffmpeg\n \ + Linux: sudo apt install ffmpeg\n \ + Or place the binary in ~/.capcut-cli/bin/" + ) +} + +/// Download the yt-dlp standalone binary for the current platform. +pub fn download_ytdlp() -> Result { + let dest = ytdlp_path(); + fs::create_dir_all(dest.parent().unwrap())?; + + let url = if cfg!(target_os = "macos") { + "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" + } else if cfg!(target_os = "linux") { + "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux" + } else { + bail!("Unsupported platform for yt-dlp binary download"); + }; + + output::log(&format!("Downloading yt-dlp from {url}...")); + + let resp = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build()? + .get(url) + .send() + .context("Failed to download yt-dlp")?; + + if !resp.status().is_success() { + bail!("yt-dlp download returned HTTP {}", resp.status()); + } + + let bytes = resp.bytes()?; + fs::write(&dest, &bytes)?; + + // Make executable + let mut perms = fs::metadata(&dest)?.permissions(); + perms.set_mode(perms.mode() | 0o755); + fs::set_permissions(&dest, perms)?; + + output::log(&format!("yt-dlp installed to {}", dest.display())); + Ok(dest) +} + +/// Check if yt-dlp is available and return status. +pub fn check_ytdlp() -> serde_json::Value { + let path = ytdlp_path(); + if !path.exists() { + return json!({ "installed": false, "path": null, "version": null }); + } + match Command::new(path.to_string_lossy().as_ref()) + .arg("--version") + .output() + { + Ok(out) => json!({ + "installed": true, + "path": path.to_string_lossy(), + "version": String::from_utf8_lossy(&out.stdout).trim().to_string(), + }), + Err(e) => json!({ + "installed": false, + "path": path.to_string_lossy(), + "error": e.to_string(), + }), + } +} + +/// Check if ffmpeg is available and return status. +pub fn check_ffmpeg() -> serde_json::Value { + match get_ffmpeg_path() { + Ok(ffmpeg) => { + match Command::new(&ffmpeg).arg("-version").output() { + Ok(out) => { + let version = String::from_utf8_lossy(&out.stdout) + .lines() + .next() + .unwrap_or("unknown") + .to_string(); + json!({ + "installed": true, + "path": ffmpeg, + "version": version, + }) + } + Err(e) => json!({ + "installed": false, + "path": ffmpeg, + "error": e.to_string(), + }), + } + } + Err(_) => json!({ "installed": false, "path": null }), + } +} + +/// Check all dependencies. +pub fn check_all() -> serde_json::Value { + json!({ + "yt_dlp": check_ytdlp(), + "ffmpeg": check_ffmpeg(), + }) +} + +/// Install all dependencies. +pub fn install_all() -> Result { + let ytdlp = if !ytdlp_path().exists() { + download_ytdlp()?; + check_ytdlp() + } else { + check_ytdlp() + }; + + Ok(json!({ + "yt_dlp": ytdlp, + "ffmpeg": check_ffmpeg(), + })) +} diff --git a/src/discover/mod.rs b/src/discover/mod.rs new file mode 100644 index 0000000..816386b --- /dev/null +++ b/src/discover/mod.rs @@ -0,0 +1,2 @@ +pub mod tiktok; +pub mod twitter; diff --git a/src/discover/tiktok.rs b/src/discover/tiktok.rs new file mode 100644 index 0000000..1809287 --- /dev/null +++ b/src/discover/tiktok.rs @@ -0,0 +1,1497 @@ +use anyhow::{bail, Result}; +use chrono::{DateTime, Duration, TimeZone, Utc}; +use regex::Regex; +use scraper::{Html, Selector}; +use serde::Serialize; +use serde_json::json; +use std::collections::{HashMap, HashSet}; + +use crate::library; +use crate::media::downloader; +use crate::output; + +const RESEARCH_API_URL: &str = "https://open.tiktokapis.com/v2/research/video/query/"; +const CREATIVE_CENTER_API: &str = + "https://ads.tiktok.com/creative_radar_api/v1/popular/sound/list"; +const CREATIVE_CENTER_URL: &str = + "https://ads.tiktok.com/business/creativecenter/pc/en"; +const CREATIVE_CENTER_SONG_URL: &str = + "https://ads.tiktok.com/business/creativecenter/song/{slug}/pc/en?countryCode={region}&period={period}"; +const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ + AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; +const RESEARCH_FIELDS: &str = "id,create_time,region_code,video_description,music_id,like_count,comment_count,share_count,view_count,username,video_duration"; +const RESEARCH_PAGE_SIZE: u32 = 100; +const RESEARCH_SAMPLE_CAP: u32 = 500; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SoundDiscoveryStrategy { + Auto, + Research, + CreativeCenter, + Library, + ManualUrl, +} + +impl SoundDiscoveryStrategy { + pub fn parse(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "auto" => Ok(Self::Auto), + "research" => Ok(Self::Research), + "creative-center" | "creative_center" | "creativecenter" => Ok(Self::CreativeCenter), + "library" => Ok(Self::Library), + "manual-url" | "manual_url" | "manual" => Ok(Self::ManualUrl), + other => bail!( + "Unknown TikTok sound discovery strategy '{other}'. Available: auto, research, creative-center, library, manual-url." + ), + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Research => "research", + Self::CreativeCenter => "creative-center", + Self::Library => "library", + Self::ManualUrl => "manual-url", + } + } +} + +#[derive(Debug, Clone)] +pub struct SoundDiscoveryOptions { + pub limit: u32, + pub region: String, + pub window_days: u32, + pub strategy: SoundDiscoveryStrategy, + pub manual_url: Option, +} + +fn debug_enabled() -> bool { + std::env::var("CAPCUT_DEBUG_DISCOVERY").ok().as_deref() == Some("1") +} + +fn debug_log(message: &str) { + if debug_enabled() { + output::log(message); + } +} + +fn http_client() -> Result { + Ok(reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::limited(10)) + .user_agent(USER_AGENT) + .build()?) +} + +fn configured_research_token() -> Option { + for name in [ + "TIKTOK_RESEARCH_ACCESS_TOKEN", + "TIKTOK_RESEARCH_CLIENT_ACCESS_TOKEN", + ] { + if let Ok(value) = std::env::var(name) { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None +} + +fn value_str(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(|v| v.as_str().map(|s| s.to_string())) + .or_else(|| value.get(key).and_then(|v| v.as_i64().map(|n| n.to_string()))) + .or_else(|| value.get(key).and_then(|v| v.as_u64().map(|n| n.to_string()))) +} + +fn value_u64(value: &serde_json::Value, key: &str) -> u64 { + value + .get(key) + .and_then(|v| v.as_u64()) + .or_else(|| value.get(key).and_then(|v| v.as_i64()).map(|v| v.max(0) as u64)) + .unwrap_or(0) +} + +fn value_i64(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(|v| v.as_i64()) + .or_else(|| value.get(key).and_then(|v| v.as_u64()).map(|v| v as i64)) +} + +fn utc_date_range(window_days: u32) -> Result<(String, String)> { + if window_days == 0 { + bail!("window_days must be at least 1."); + } + + let end = Utc::now().date_naive(); + let start = end + .checked_sub_signed(Duration::days(window_days.saturating_sub(1) as i64)) + .unwrap_or(end); + Ok(( + start.format("%Y%m%d").to_string(), + end.format("%Y%m%d").to_string(), + )) +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +struct ResearchVideo { + id: String, + create_time: i64, + region_code: String, + video_description: String, + music_id: String, + like_count: u64, + comment_count: u64, + share_count: u64, + view_count: u64, + username: String, +} + +impl ResearchVideo { + fn age_days(&self, now_ts: i64) -> f64 { + let age_seconds = now_ts.saturating_sub(self.create_time).max(0) as f64; + age_seconds / 86_400.0 + } + + fn engagement_score(&self) -> f64 { + (self.like_count as f64).ln_1p() * 2.0 + + (self.share_count as f64).ln_1p() * 2.8 + + (self.comment_count as f64).ln_1p() * 1.3 + + (self.view_count as f64).ln_1p() * 0.35 + } + + fn recency_weight(&self, now_ts: i64, window_days: u32) -> f64 { + let window = window_days.max(1) as f64; + let freshness = (1.0 - (self.age_days(now_ts) / window)).clamp(0.05, 1.0); + freshness.powf(1.35) + } + + fn contribution(&self, now_ts: i64, window_days: u32) -> f64 { + self.recency_weight(now_ts, window_days) * (1.0 + self.engagement_score()) + } +} + +#[derive(Debug, Clone)] +struct CandidateAggregate { + music_id: String, + score: f64, + video_count: u64, + total_views: u64, + total_likes: u64, + total_comments: u64, + total_shares: u64, + latest_video: Option, +} + +impl CandidateAggregate { + fn new(music_id: String) -> Self { + Self { + music_id, + score: 0.0, + video_count: 0, + total_views: 0, + total_likes: 0, + total_comments: 0, + total_shares: 0, + latest_video: None, + } + } + + fn add_video(&mut self, video: ResearchVideo, now_ts: i64, window_days: u32) { + self.video_count += 1; + self.total_views += video.view_count; + self.total_likes += video.like_count; + self.total_comments += video.comment_count; + self.total_shares += video.share_count; + self.score += 40.0 + video.contribution(now_ts, window_days) * 25.0; + + match &self.latest_video { + Some(existing) if existing.create_time >= video.create_time => {} + _ => self.latest_video = Some(video), + } + } + + fn finalize_score(&mut self) { + self.score += (self.video_count as f64).powf(1.15) * 75.0; + self.score += (self.total_views as f64).ln_1p() * 1.5; + } +} + +#[derive(Debug, Clone, Serialize)] +struct TrendingSoundCandidate { + rank: u64, + music_id: String, + title: String, + artist: String, + tiktok_url: String, + import_url: String, + import_hint: String, + source_path: String, + source_url: String, + ranking_score: f64, + video_count: u64, + total_views: u64, + total_likes: u64, + total_comments: u64, + total_shares: u64, + latest_video_create_time: String, + #[serde(skip_serializing_if = "Option::is_none")] + enrichment_source: Option, +} + +fn tiktok_music_url(music_id: &str) -> String { + format!("https://www.tiktok.com/music/_-{music_id}") +} + +fn creative_center_song_url(slug: &str, region: &str, period: u32) -> String { + CREATIVE_CENTER_SONG_URL + .replace("{slug}", slug) + .replace("{region}", region) + .replace("{period}", &period.to_string()) +} + +fn parse_title_artist(page_title: &str) -> Option<(String, String)> { + let leading = page_title.split(" | ").next()?.trim(); + if let Some((title, artist)) = leading.split_once(" created by ") { + return Some((title.trim().to_string(), artist.trim().to_string())); + } + if let Some((title, artist)) = leading.split_once(" by ") { + return Some((title.trim().to_string(), artist.trim().to_string())); + } + None +} + +fn extract_cover_url(document: &Html) -> String { + let selector = match Selector::parse("meta[property=\"og:image\"], meta[name=\"twitter:image\"]") + { + Ok(sel) => sel, + Err(_) => return String::new(), + }; + + for meta in document.select(&selector) { + if let Some(content) = meta.value().attr("content") { + if !content.trim().is_empty() { + return content.to_string(); + } + } + } + + String::new() +} + +fn extract_view_more_link(document: &Html) -> String { + let selector = match Selector::parse("a[href]") { + Ok(sel) => sel, + Err(_) => return String::new(), + }; + + for anchor in document.select(&selector) { + let label = anchor.text().collect::(); + let Some(href) = anchor.value().attr("href") else { + continue; + }; + if label.contains("View more on TikTok") || label.contains("View on TikTok") { + return href.to_string(); + } + } + + String::new() +} + +fn extract_detail_payload(document: &Html) -> Option { + let selector = Selector::parse("script#__NEXT_DATA__").ok()?; + let text = document.select(&selector).next()?.text().collect::(); + let data: serde_json::Value = serde_json::from_str(&text).ok()?; + Some(data.get("props")?.get("pageProps")?.get("data")?.clone()) +} + +fn build_import_url(payload: Option<&serde_json::Value>, tiktok_url: &str) -> (String, String) { + let preview_audio_url = payload + .and_then(|p| p.get("musicUrl")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let import_url = if !preview_audio_url.is_empty() { + preview_audio_url.clone() + } else if let Some(item_id) = payload + .and_then(|p| p.get("relatedItems")) + .and_then(|v| v.as_array()) + .and_then(|items| items.first()) + .and_then(|item| item.get("itemId")) + .and_then(|v| v.as_str()) + { + format!("https://www.tiktok.com/embed/v2/{item_id}") + } else { + tiktok_url.to_string() + }; + + (import_url, preview_audio_url) +} + +fn extract_song_detail_links(document: &Html) -> Vec { + let selector = match Selector::parse("a[href]") { + Ok(sel) => sel, + Err(_) => return Vec::new(), + }; + + let mut seen = HashSet::new(); + let mut links = Vec::new(); + + for anchor in document.select(&selector) { + let Some(href) = anchor.value().attr("href") else { + continue; + }; + if !href.contains("/business/creativecenter/song/") { + continue; + } + + let url = if href.starts_with("http://") || href.starts_with("https://") { + href.to_string() + } else { + format!("https://ads.tiktok.com{href}") + }; + if seen.insert(url.clone()) { + links.push(url); + } + } + + links +} + +fn extract_next_data(document: &Html) -> Option> { + let sel = Selector::parse("script#__NEXT_DATA__").ok()?; + let el = document.select(&sel).next()?; + let text = el.text().collect::(); + let data: serde_json::Value = serde_json::from_str(&text).ok()?; + + let list = data + .get("props")? + .get("pageProps")? + .get("data")? + .get("soundList")? + .as_array()?; + + if list.is_empty() { + None + } else { + Some(list.clone()) + } +} + +fn extract_script_scan(document: &Html) -> Option> { + let sel = Selector::parse("script").ok()?; + + for el in document.select(&sel) { + let text = el.text().collect::(); + if !text.contains("soundList") && !text.contains("sound_list") { + continue; + } + let data: serde_json::Value = match serde_json::from_str(&text) { + Ok(d) => d, + Err(_) => continue, + }; + + let paths: Vec Option<&serde_json::Value>>> = vec![ + Box::new(|d| d.get("props")?.get("pageProps")?.get("data")?.get("soundList")), + Box::new(|d| d.get("props")?.get("pageProps")?.get("soundList")), + Box::new(|d| d.get("data")?.get("soundList")), + Box::new(|d| d.get("data")?.get("sound_list")), + Box::new(|d| d.get("soundList")), + ]; + + for path_fn in &paths { + if let Some(list) = path_fn(&data).and_then(|v| v.as_array()) { + if !list.is_empty() { + return Some(list.clone()); + } + } + } + } + + None +} + +fn extract_regex(html: &str) -> Option> { + for key in &["soundList", "sound_list"] { + let pattern = format!(r#""{key}"\s*:\s*(\[.*?\])\s*[,}}\]]"#); + if let Ok(re) = Regex::new(&pattern) { + if let Some(caps) = re.captures(html) { + if let Some(arr_str) = caps.get(1) { + if let Ok(arr) = serde_json::from_str::>(arr_str.as_str()) + { + if !arr.is_empty() { + return Some(arr); + } + } + } + } + } + } + None +} + +fn parse_research_video(value: &serde_json::Value) -> Option { + let id = value_str(value, "id")?; + let create_time = value_i64(value, "create_time")?; + let music_id = value_str(value, "music_id")?; + + Some(ResearchVideo { + id, + create_time, + region_code: value_str(value, "region_code").unwrap_or_else(|| "unknown".to_string()), + video_description: value_str(value, "video_description").unwrap_or_default(), + music_id, + like_count: value_u64(value, "like_count"), + comment_count: value_u64(value, "comment_count"), + share_count: value_u64(value, "share_count"), + view_count: value_u64(value, "view_count"), + username: value_str(value, "username").unwrap_or_default(), + }) +} + +fn parse_research_response(body: &serde_json::Value) -> Result<(Vec, bool, i64)> { + let data = body + .get("data") + .ok_or_else(|| anyhow::anyhow!("TikTok Research API response missing data field."))?; + let videos = data + .get("videos") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("TikTok Research API response missing videos array."))?; + + let has_more = data + .get("has_more") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let cursor = data + .get("cursor") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + let parsed = videos.iter().filter_map(parse_research_video).collect(); + Ok((parsed, has_more, cursor)) +} + +fn research_video_page( + client: &reqwest::blocking::Client, + token: &str, + region: &str, + window_days: u32, + cursor: i64, +) -> Result<(Vec, bool, i64)> { + let (start_date, end_date) = utc_date_range(window_days)?; + let body = json!({ + "query": { + "and": [ + { + "operation": "EQ", + "field_name": "region_code", + "field_values": [region], + } + ] + }, + "max_count": RESEARCH_PAGE_SIZE, + "cursor": cursor, + "start_date": start_date, + "end_date": end_date, + }); + + let resp = client + .post(RESEARCH_API_URL) + .bearer_auth(token) + .header("Content-Type", "application/json") + .query(&[("fields", RESEARCH_FIELDS)]) + .json(&body) + .send() + .map_err(|e| anyhow::anyhow!("TikTok Research API request failed: {e}"))?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED + || resp.status() == reqwest::StatusCode::FORBIDDEN + { + bail!("TikTok Research API access token was rejected."); + } + + if !resp.status().is_success() { + bail!( + "TikTok Research API returned status {}.", + resp.status().as_u16() + ); + } + + let body: serde_json::Value = resp + .json() + .map_err(|e| anyhow::anyhow!("TikTok Research API response could not be parsed: {e}"))?; + + if let Some(error) = body.get("error") { + let code = error.get("code").and_then(|v| v.as_str()).unwrap_or(""); + if code != "ok" && !code.is_empty() { + let message = error.get("message").and_then(|v| v.as_str()).unwrap_or(""); + bail!("TikTok Research API returned error {code}: {message}"); + } + } + + parse_research_response(&body) +} + +fn fetch_research_videos(limit: u32, region: &str, window_days: u32) -> Result> { + let token = configured_research_token().ok_or_else(|| { + anyhow::anyhow!( + "TikTok Research API access token not configured. Set TIKTOK_RESEARCH_ACCESS_TOKEN or TIKTOK_RESEARCH_CLIENT_ACCESS_TOKEN." + ) + })?; + let client = http_client()?; + + let mut cursor = 0; + let mut collected = Vec::new(); + let target = RESEARCH_SAMPLE_CAP.max(limit.saturating_mul(40)); + + loop { + let (videos, has_more, next_cursor) = + research_video_page(&client, &token, region, window_days, cursor)?; + if videos.is_empty() { + break; + } + + collected.extend(videos); + if collected.len() as u32 >= target || !has_more || next_cursor == cursor { + break; + } + cursor = next_cursor; + } + + Ok(collected) +} + +fn parse_song_detail_html(html: &str, page_url: &str, region: &str, period: u32) -> Option { + let document = Html::parse_document(html); + let payload = extract_detail_payload(&document); + + let title_selector = Selector::parse("title").ok()?; + let page_title = document + .select(&title_selector) + .next() + .map(|el| el.text().collect::()) + .unwrap_or_default(); + + let (fallback_title, fallback_artist) = parse_title_artist(&page_title)?; + let title = payload + .as_ref() + .and_then(|p| p.get("title")) + .and_then(|v| v.as_str()) + .unwrap_or(&fallback_title) + .to_string(); + let artist = payload + .as_ref() + .and_then(|p| p.get("author")) + .and_then(|v| v.as_str()) + .unwrap_or(&fallback_artist) + .to_string(); + let tiktok_url = payload + .as_ref() + .and_then(|p| p.get("link")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| extract_view_more_link(&document)); + let cover_url = payload + .as_ref() + .and_then(|p| p.get("cover")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| extract_cover_url(&document)); + let duration_seconds = payload + .as_ref() + .and_then(|p| p.get("duration")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let (import_url, preview_audio_url) = build_import_url(payload.as_ref(), &tiktok_url); + + Some(json!({ + "rank": 0, + "title": title, + "artist": artist, + "music_id": payload + .as_ref() + .and_then(|p| p.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + "tiktok_url": tiktok_url, + "import_url": import_url, + "preview_audio_url": preview_audio_url, + "cover_url": cover_url, + "duration_seconds": duration_seconds, + "is_promoted": false, + "analytics_url": page_url, + "source_path": "tiktok_creative_center_song_page", + "source_region": region, + "source_period_days": period, + })) +} + +fn parse_candidate_from_raw( + raw: &serde_json::Value, + rank: usize, + source_path: &str, + _region: &str, + _period: u32, +) -> TrendingSoundCandidate { + let title = raw + .get("title") + .and_then(|v| v.as_str()) + .or_else(|| raw.get("musicName").and_then(|v| v.as_str())) + .unwrap_or("Unknown") + .to_string(); + let artist = raw + .get("artist") + .and_then(|v| v.as_str()) + .or_else(|| raw.get("author").and_then(|v| v.as_str())) + .or_else(|| raw.get("artistName").and_then(|v| v.as_str())) + .or_else(|| raw.get("creator").and_then(|c| c.get("nickname")).and_then(|v| v.as_str())) + .unwrap_or("Unknown") + .to_string(); + let music_id = raw + .get("music_id") + .and_then(|v| v.as_str()) + .or_else(|| raw.get("musicId").and_then(|v| v.as_str())) + .unwrap_or("") + .to_string(); + let tiktok_url = raw + .get("tiktok_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let import_url = raw + .get("import_url") + .and_then(|v| v.as_str()) + .unwrap_or(&tiktok_url) + .to_string(); + let import_hint = format!("capcut-cli library import \"{import_url}\" --type sound"); + let latest_video_create_time = raw + .get("latest_video_create_time") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + TrendingSoundCandidate { + rank: rank as u64, + music_id, + title, + artist, + tiktok_url, + import_url: import_url.clone(), + import_hint, + source_path: source_path.to_string(), + source_url: raw + .get("source_url") + .and_then(|v| v.as_str()) + .or_else(|| raw.get("analytics_url").and_then(|v| v.as_str())) + .unwrap_or(&import_url) + .to_string(), + ranking_score: raw + .get("ranking_score") + .and_then(|v| v.as_f64()) + .unwrap_or_else(|| limit_rank_bonus(rank) as f64), + video_count: raw.get("video_count").and_then(|v| v.as_u64()).unwrap_or(0), + total_views: raw.get("total_views").and_then(|v| v.as_u64()).unwrap_or(0), + total_likes: raw.get("total_likes").and_then(|v| v.as_u64()).unwrap_or(0), + total_comments: raw.get("total_comments").and_then(|v| v.as_u64()).unwrap_or(0), + total_shares: raw.get("total_shares").and_then(|v| v.as_u64()).unwrap_or(0), + latest_video_create_time, + enrichment_source: raw + .get("enrichment_source") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + } +} + +fn limit_rank_bonus(rank: usize) -> u64 { + 1000_u64.saturating_sub(rank as u64) +} + +fn enrich_research_candidate(candidate: &mut TrendingSoundCandidate, region: &str, window_days: u32) { + let slug = candidate.music_id.clone(); + let page_url = creative_center_song_url(&slug, region, window_days); + + let Ok(client) = http_client() else { + return; + }; + + let Ok(resp) = client + .get(&page_url) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.9") + .send() + else { + return; + }; + + if !resp.status().is_success() { + return; + } + + let Ok(html) = resp.text() else { + return; + }; + + let Some(detail) = parse_song_detail_html(&html, &page_url, region, window_days) else { + return; + }; + + if let Some(title) = detail.get("title").and_then(|v| v.as_str()) { + candidate.title = title.to_string(); + } + if let Some(artist) = detail.get("artist").and_then(|v| v.as_str()) { + candidate.artist = artist.to_string(); + } + if let Some(import_url) = detail.get("import_url").and_then(|v| v.as_str()) { + candidate.import_url = import_url.to_string(); + candidate.import_hint = format!("capcut-cli library import \"{import_url}\" --type sound"); + } + if let Some(tiktok_url) = detail.get("tiktok_url").and_then(|v| v.as_str()) { + candidate.tiktok_url = tiktok_url.to_string(); + candidate.source_url = tiktok_url.to_string(); + } else { + candidate.source_url = page_url.clone(); + } + candidate.enrichment_source = Some("tiktok_creative_center_song_page".to_string()); +} + +fn candidate_from_research( + agg: CandidateAggregate, + rank: usize, + region: &str, + _window_days: u32, +) -> TrendingSoundCandidate { + let latest = agg.latest_video.unwrap_or_else(|| ResearchVideo { + id: String::new(), + create_time: Utc::now().timestamp(), + region_code: region.to_string(), + video_description: String::new(), + music_id: agg.music_id.clone(), + like_count: 0, + comment_count: 0, + share_count: 0, + view_count: 0, + username: String::new(), + }); + let tiktok_url = tiktok_music_url(&agg.music_id); + let latest_video_create_time = Utc + .timestamp_opt(latest.create_time, 0) + .single() + .unwrap_or_else(Utc::now) + .to_rfc3339(); + + TrendingSoundCandidate { + rank: rank as u64, + music_id: agg.music_id, + title: format!("TikTok sound {}", latest.music_id), + artist: "Unknown".to_string(), + tiktok_url: tiktok_url.clone(), + import_url: tiktok_url.clone(), + import_hint: format!("capcut-cli library import \"{tiktok_url}\" --type sound"), + source_path: "tiktok_research_api".to_string(), + source_url: tiktok_url, + ranking_score: (agg.score * 1000.0).round() / 1000.0, + video_count: agg.video_count, + total_views: agg.total_views, + total_likes: agg.total_likes, + total_comments: agg.total_comments, + total_shares: agg.total_shares, + latest_video_create_time, + enrichment_source: None, + } +} + +fn research_candidates(limit: u32, region: &str, window_days: u32) -> Result> { + let videos = fetch_research_videos(limit, region, window_days)?; + if videos.is_empty() { + return Ok(vec![]); + } + + let now_ts = Utc::now().timestamp(); + let mut buckets: HashMap = HashMap::new(); + for video in videos { + let entry = buckets + .entry(video.music_id.clone()) + .or_insert_with(|| CandidateAggregate::new(video.music_id.clone())); + entry.add_video(video, now_ts, window_days); + } + + let mut aggregates: Vec<_> = buckets + .into_values() + .map(|mut agg| { + agg.finalize_score(); + agg + }) + .collect(); + + aggregates.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| b.video_count.cmp(&a.video_count)) + .then_with(|| b.total_views.cmp(&a.total_views)) + .then_with(|| a.music_id.cmp(&b.music_id)) + }); + + let mut candidates: Vec<_> = aggregates + .into_iter() + .take(limit as usize) + .enumerate() + .map(|(index, agg)| candidate_from_research(agg, index + 1, region, window_days)) + .collect(); + + for candidate in &mut candidates { + enrich_research_candidate(candidate, region, window_days); + } + + Ok(candidates) +} + +fn normalize_candidate_scores(candidates: &mut [TrendingSoundCandidate]) { + if candidates.is_empty() { + return; + } + + let max_score = candidates + .iter() + .map(|candidate| candidate.ranking_score) + .fold(f64::MIN, f64::max); + if !max_score.is_finite() || max_score <= 0.0 { + return; + } + + for candidate in candidates { + candidate.ranking_score = (candidate.ranking_score / max_score * 1000.0).round() / 1000.0; + } +} + +fn try_creative_center_api(limit: u32, region: &str, period: u32) -> Option> { + let client = http_client().ok()?; + let period_s = period.to_string(); + let limit_s = limit.to_string(); + let resp = client + .get(CREATIVE_CENTER_API) + .header("Accept", "application/json") + .query(&[ + ("period", period_s.as_str()), + ("page", "1"), + ("limit", limit_s.as_str()), + ("country_code", region), + ("sort_by", "popularity"), + ]) + .send() + .ok()?; + + debug_log(&format!("TikTok Creative Center API status: {}", resp.status())); + if !resp.status().is_success() { + return None; + } + + let body: serde_json::Value = resp.json().ok()?; + let data = body.get("data")?; + let sound_list = data + .get("sound_list") + .or_else(|| data.get("soundList")) + .or_else(|| data.get("list"))?; + let arr = sound_list.as_array()?; + if arr.is_empty() { + return None; + } + + output::log("Source: Creative Center API (JSON)"); + Some(arr.clone()) +} + +fn try_creative_center_detail_crawl(limit: u32, region: &str, period: u32) -> Option> { + let client = http_client().ok()?; + let period_s = period.to_string(); + let overview_url = format!("{CREATIVE_CENTER_URL}?countryCode={region}&period={period_s}"); + let resp = client + .get(&overview_url) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.9") + .send() + .ok()?; + + debug_log(&format!("TikTok Creative Center overview status: {}", resp.status())); + if !resp.status().is_success() { + return None; + } + + let html = resp.text().ok()?; + debug_log(&format!("TikTok Creative Center overview HTML bytes: {}", html.len())); + let document = Html::parse_document(&html); + let links = extract_song_detail_links(&document); + debug_log(&format!("TikTok song detail links found: {}", links.len())); + if links.is_empty() { + return None; + } + + output::log("Source: Creative Center overview HTML (song detail crawl)"); + let mut songs = Vec::new(); + for url in links { + let rank = songs.len() + 1; + let Ok(resp) = client + .get(&url) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.9") + .send() + else { + continue; + }; + if !resp.status().is_success() { + continue; + } + let Ok(html) = resp.text() else { + continue; + }; + if let Some(parsed) = parse_song_detail_html(&html, &url, region, period) { + let mut parsed = parsed; + if let Some(obj) = parsed.as_object_mut() { + obj.insert("rank".to_string(), json!(rank)); + } + songs.push(parsed); + } + if songs.len() >= limit as usize { + break; + } + } + + if songs.is_empty() { + None + } else { + Some(songs) + } +} + +fn try_creative_center_html(_region: &str, period: u32) -> Option> { + let client = http_client().ok()?; + let period_s = period.to_string(); + let resp = client + .get(CREATIVE_CENTER_URL) + .query(&[("period", period_s.as_str())]) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.9") + .send() + .ok()?; + + debug_log(&format!("TikTok legacy HTML status: {}", resp.status())); + if !resp.status().is_success() { + return None; + } + + let html = resp.text().ok()?; + debug_log(&format!("TikTok legacy HTML bytes: {}", html.len())); + let document = Html::parse_document(&html); + + let strategies: Vec<(&str, Box Option>>)> = vec![ + ("__NEXT_DATA__", Box::new(|| extract_next_data(&document))), + ("script-scan", Box::new(|| extract_script_scan(&document))), + ("regex", Box::new(|| extract_regex(&html))), + ]; + + for (name, strategy) in &strategies { + if let Some(result) = strategy() { + output::log(&format!("Source: Creative Center HTML ({name})")); + return Some(result); + } + } + + None +} + +fn fallback_creative_center_sounds(limit: u32, region: &str, period: u32) -> Result> { + let raw_sounds = try_creative_center_api(limit, region, period) + .or_else(|| try_creative_center_detail_crawl(limit, region, period)) + .or_else(|| try_creative_center_html(region, period)) + .ok_or_else(|| { + anyhow::anyhow!( + "Could not extract trending sounds from TikTok Creative Center. Both the JSON API and HTML extraction failed — the page structure may have changed." + ) + })?; + + let mut candidates: Vec<_> = raw_sounds + .iter() + .take(limit as usize) + .enumerate() + .map(|(i, raw)| { + let mut candidate = parse_candidate_from_raw(raw, i + 1, "tiktok_creative_center", region, period); + if candidate.music_id.is_empty() { + candidate.music_id = candidate + .import_url + .rsplit('/') + .next() + .unwrap_or("unknown") + .to_string(); + } + candidate.source_url = raw + .get("analytics_url") + .and_then(|v| v.as_str()) + .unwrap_or(&candidate.import_url) + .to_string(); + candidate + }) + .collect(); + + normalize_candidate_scores(&mut candidates); + Ok(candidates) +} + +fn candidates_to_json( + candidates: Vec, + method: &str, + region: &str, + window_days: u32, + recommended: bool, +) -> serde_json::Value { + json!({ + "method": method, + "recommended": recommended, + "source_path": method, + "region": region, + "window_days": window_days, + "total_found": candidates.len(), + "sounds": candidates, + "import_hint": "Use a candidate's import_url with: capcut-cli library import --type sound", + }) +} + +fn library_sound_score(asset: &crate::models::Asset) -> f64 { + let recency = DateTime::parse_from_rfc3339(&asset.downloaded_at) + .ok() + .map(|dt| { + let age_hours = (Utc::now() - dt.with_timezone(&Utc)).num_hours().max(0) as f64; + (72.0 - age_hours).max(0.0) + }) + .unwrap_or(0.0); + let trending_bonus = if asset.tags.iter().any(|tag| { + let lowered = tag.to_ascii_lowercase(); + lowered.contains("trend") || lowered.contains("tiktok") + }) { + 100.0 + } else { + 0.0 + }; + recency + trending_bonus + asset.duration_seconds.min(60.0) +} + +fn library_candidates(limit: u32, region: &str, window_days: u32) -> Result> { + let mut assets = library::list_assets(Some("sound"))?; + if assets.is_empty() { + bail!("No local sound assets are available in the library."); + } + + assets.sort_by(|a, b| { + library_sound_score(b) + .partial_cmp(&library_sound_score(a)) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| b.downloaded_at.cmp(&a.downloaded_at)) + }); + + let candidates = assets + .into_iter() + .take(limit as usize) + .enumerate() + .map(|(index, asset)| TrendingSoundCandidate { + rank: (index + 1) as u64, + music_id: asset.id.clone(), + title: asset.title.clone(), + artist: "Library".to_string(), + tiktok_url: asset.source_url.clone(), + import_url: asset.source_url.clone(), + import_hint: format!("Reuse existing library sound asset: {}", asset.id), + source_path: "library".to_string(), + source_url: asset.source_url.clone(), + ranking_score: (library_sound_score(&asset) * 1000.0).round() / 1000.0, + video_count: 0, + total_views: 0, + total_likes: 0, + total_comments: 0, + total_shares: 0, + latest_video_create_time: asset.downloaded_at.clone(), + enrichment_source: Some("library_asset".to_string()), + }) + .collect(); + + let _ = region; + let _ = window_days; + Ok(candidates) +} + +fn manual_url_candidates( + limit: u32, + region: &str, + window_days: u32, + manual_url: &str, +) -> Result> { + let info = downloader::get_info(manual_url).ok(); + let title = info + .as_ref() + .and_then(|v| v.get("title")) + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + .unwrap_or("Manual sound URL") + .to_string(); + let artist = info + .as_ref() + .and_then(|v| v.get("uploader")) + .and_then(|v| v.as_str()) + .or_else(|| info.as_ref().and_then(|v| v.get("channel")).and_then(|v| v.as_str())) + .unwrap_or("Manual") + .to_string(); + + let candidate = TrendingSoundCandidate { + rank: 1, + music_id: "manual-url".to_string(), + title, + artist, + tiktok_url: manual_url.to_string(), + import_url: manual_url.to_string(), + import_hint: format!("capcut-cli library import \"{manual_url}\" --type sound"), + source_path: "manual-url".to_string(), + source_url: manual_url.to_string(), + ranking_score: 1.0, + video_count: 0, + total_views: 0, + total_likes: 0, + total_comments: 0, + total_shares: 0, + latest_video_create_time: Utc::now().to_rfc3339(), + enrichment_source: Some("manual_url".to_string()), + }; + + let _ = limit; + let _ = region; + let _ = window_days; + Ok(vec![candidate]) +} + +/// Fetch trending sounds using an explicit strategy or `auto`. +pub fn find_trending_sounds_with_options(options: &SoundDiscoveryOptions) -> Result { + let limit = options.limit; + let region = options.region.as_str(); + let window_days = options.window_days; + output::log(&format!( + "Fetching trending TikTok sounds (strategy={}, region={region}, window_days={window_days}, limit={limit})...", + options.strategy.as_str() + )); + + match options.strategy { + SoundDiscoveryStrategy::Research => { + let candidates = research_candidates(limit, region, window_days)?; + if candidates.is_empty() { + bail!("TikTok Research API returned no ranked sounds for the requested window."); + } + output::log("Source: TikTok Research API"); + return Ok(candidates_to_json( + candidates, + "tiktok_research_api", + region, + window_days, + true, + )); + } + SoundDiscoveryStrategy::CreativeCenter => { + let candidates = fallback_creative_center_sounds(limit, region, window_days)?; + return Ok(candidates_to_json( + candidates, + "tiktok_creative_center", + region, + window_days, + false, + )); + } + SoundDiscoveryStrategy::Library => { + let candidates = library_candidates(limit, region, window_days)?; + return Ok(candidates_to_json(candidates, "library", region, window_days, false)); + } + SoundDiscoveryStrategy::ManualUrl => { + let manual_url = options + .manual_url + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("manual-url strategy requires --sound-url."))?; + let candidates = manual_url_candidates(limit, region, window_days, manual_url)?; + return Ok(candidates_to_json(candidates, "manual-url", region, window_days, false)); + } + SoundDiscoveryStrategy::Auto => {} + } + + if options + .manual_url + .as_deref() + .filter(|value| !value.trim().is_empty()) + .is_some() + { + let manual_url = options.manual_url.as_deref().unwrap(); + let candidates = manual_url_candidates(limit, region, window_days, manual_url)?; + return Ok(candidates_to_json(candidates, "manual-url", region, window_days, false)); + } + + if configured_research_token().is_some() { + match research_candidates(limit, region, window_days) { + Ok(candidates) if !candidates.is_empty() => { + output::log("Source: TikTok Research API"); + return Ok(candidates_to_json( + candidates, + "tiktok_research_api", + region, + window_days, + true, + )); + } + Ok(_) => { + output::log("TikTok Research API returned no ranked sounds; falling back to Creative Center."); + } + Err(err) => { + debug_log(&format!("TikTok Research API fallback path: {err}")); + output::log("TikTok Research API discovery failed; falling back to Creative Center."); + } + } + } + + match fallback_creative_center_sounds(limit, region, window_days) { + Ok(candidates) => Ok(candidates_to_json( + candidates, + "tiktok_creative_center", + region, + window_days, + false, + )), + Err(err) => { + debug_log(&format!("Creative Center fallback path: {err}")); + let candidates = library_candidates(limit, region, window_days)?; + output::log("Creative Center discovery failed; falling back to local library sounds."); + Ok(candidates_to_json(candidates, "library", region, window_days, false)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_research_fixture(name: &str) -> serde_json::Value { + let json = match name { + "research_response_page_1.json" => { + include_str!("../../tests/fixtures/tiktok/research_response_page_1.json") + } + "research_response_page_2.json" => { + include_str!("../../tests/fixtures/tiktok/research_response_page_2.json") + } + other => panic!("unknown research fixture: {other}"), + }; + serde_json::from_str(json).unwrap() + } + + #[test] + fn test_music_url_format_uses_music_id() { + assert_eq!( + tiktok_music_url("7310129403294828545"), + "https://www.tiktok.com/music/_-7310129403294828545" + ); + } + + #[test] + fn test_strategy_parse_accepts_aliases() { + assert_eq!( + SoundDiscoveryStrategy::parse("creative-center").unwrap(), + SoundDiscoveryStrategy::CreativeCenter + ); + assert_eq!( + SoundDiscoveryStrategy::parse("manual").unwrap(), + SoundDiscoveryStrategy::ManualUrl + ); + } + + #[test] + fn test_strategy_parse_rejects_unknown_values() { + let error = SoundDiscoveryStrategy::parse("totally-unknown").unwrap_err(); + + assert!( + error + .to_string() + .contains("Unknown TikTok sound discovery strategy") + ); + } + + #[test] + fn test_manual_url_candidates_use_manual_source() { + let candidates = manual_url_candidates(5, "US", 7, "https://www.tiktok.com/music/_-123") + .unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].source_path, "manual-url"); + assert_eq!(candidates[0].import_url, "https://www.tiktok.com/music/_-123"); + } + + #[test] + fn test_find_trending_sounds_with_options_manual_url_returns_manual_method() { + let payload = find_trending_sounds_with_options(&SoundDiscoveryOptions { + limit: 3, + region: "US".to_string(), + window_days: 7, + strategy: SoundDiscoveryStrategy::ManualUrl, + manual_url: Some("https://www.tiktok.com/music/_-123".to_string()), + }) + .unwrap(); + + assert_eq!(payload.get("method").and_then(|v| v.as_str()), Some("manual-url")); + assert_eq!(payload.get("recommended").and_then(|v| v.as_bool()), Some(false)); + } + + #[test] + fn test_library_candidates_return_existing_assets_when_available() { + let candidates = library_candidates(10, "US", 7).unwrap(); + assert!(!candidates.is_empty()); + assert_eq!(candidates[0].source_path, "library"); + } + + #[test] + fn test_ranking_prefers_frequent_recent_sound() { + let now = 1_735_689_600; + let videos = vec![ + ResearchVideo { + id: "1".into(), + create_time: now - 3600, + region_code: "US".into(), + video_description: "recent one".into(), + music_id: "100".into(), + like_count: 300, + comment_count: 20, + share_count: 15, + view_count: 20_000, + username: "a".into(), + }, + ResearchVideo { + id: "2".into(), + create_time: now - 4200, + region_code: "US".into(), + video_description: "recent two".into(), + music_id: "100".into(), + like_count: 250, + comment_count: 18, + share_count: 11, + view_count: 18_000, + username: "b".into(), + }, + ResearchVideo { + id: "3".into(), + create_time: now - 10 * 86_400, + region_code: "US".into(), + video_description: "older but loud".into(), + music_id: "200".into(), + like_count: 5_000, + comment_count: 400, + share_count: 300, + view_count: 90_000, + username: "c".into(), + }, + ]; + + let mut buckets: HashMap = HashMap::new(); + for video in videos { + let entry = buckets + .entry(video.music_id.clone()) + .or_insert_with(|| CandidateAggregate::new(video.music_id.clone())); + entry.add_video(video, now, 7); + } + let mut aggregates: Vec<_> = buckets + .into_values() + .map(|mut agg| { + agg.finalize_score(); + agg + }) + .collect(); + aggregates.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + assert_eq!(aggregates.first().unwrap().music_id, "100"); + } + + #[test] + fn test_parse_research_response_fixture() { + let body = parse_research_fixture("research_response_page_1.json"); + let (videos, has_more, cursor) = parse_research_response(&body).unwrap(); + assert!(has_more); + assert_eq!(cursor, 100); + assert_eq!(videos.len(), 4); + assert_eq!(videos[0].music_id, "7001"); + } + + #[test] + fn test_research_ranking_fixture_orders_by_frequency() { + let page_1 = parse_research_fixture("research_response_page_1.json"); + let page_2 = parse_research_fixture("research_response_page_2.json"); + + let mut videos = Vec::new(); + videos.extend(parse_research_response(&page_1).unwrap().0); + videos.extend(parse_research_response(&page_2).unwrap().0); + + let candidates = { + let now_ts = Utc::now().timestamp(); + let mut buckets: HashMap = HashMap::new(); + for video in videos { + let entry = buckets + .entry(video.music_id.clone()) + .or_insert_with(|| CandidateAggregate::new(video.music_id.clone())); + entry.add_video(video, now_ts, 7); + } + let mut aggregates: Vec<_> = buckets + .into_values() + .map(|mut agg| { + agg.finalize_score(); + agg + }) + .collect(); + aggregates.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + aggregates + }; + + assert_eq!(candidates.first().unwrap().music_id, "7001"); + assert!(candidates.first().unwrap().video_count >= 3); + } + + #[test] + fn test_parse_song_detail_fixture_extracts_import_data() { + let html = include_str!("../../tests/fixtures/tiktok/creative_center_song_detail.html"); + let parsed = parse_song_detail_html( + html, + "https://ads.tiktok.com/business/creativecenter/song/example/pc/en?countryCode=US&period=7", + "US", + 7, + ) + .unwrap(); + + assert_eq!(parsed.get("title").and_then(|v| v.as_str()), Some("Mặt Trời Đã Khuất")); + assert_eq!(parsed.get("artist").and_then(|v| v.as_str()), Some("VORTEXX BAND")); + assert!(parsed + .get("import_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .contains("tiktok.com")); + } + + #[test] + fn test_extract_song_links_from_overview_fixture() { + let html = include_str!("../../tests/fixtures/tiktok/creative_center_overview.html"); + let document = Html::parse_document(html); + let links = extract_song_detail_links(&document); + assert_eq!(links.len(), 2); + assert!(links[0].contains("/business/creativecenter/song/")); + } +} diff --git a/src/discover/twitter.rs b/src/discover/twitter.rs new file mode 100644 index 0000000..f71bd31 --- /dev/null +++ b/src/discover/twitter.rs @@ -0,0 +1,657 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde_json::json; +use thiserror::Error; + +use crate::library; +use crate::output; + +const TWITTER_SEARCH_V2: &str = "https://api.twitter.com/2/tweets/search/recent"; + +#[derive(Debug, Error)] +pub enum TwitterDiscoveryError { + #[error("TWITTER_BEARER_TOKEN is required for reliable X/Twitter clip discovery.")] + AuthRequired, + #[error("X/Twitter API rate limit reached. Retry later.")] + RateLimited, + #[error("X/Twitter API request failed: {message}")] + ApiRequest { message: String }, + #[error("X/Twitter API returned status {status}.")] + ApiStatus { status: u16 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClipDiscoveryStrategy { + Auto, + Api, + Guided, + Library, + ManualUrl, +} + +impl ClipDiscoveryStrategy { + pub fn parse(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "auto" => Ok(Self::Auto), + "api" | "twitter-api" | "x-api" => Ok(Self::Api), + "guided" | "guided-fallback" | "browser" => Ok(Self::Guided), + "library" => Ok(Self::Library), + "manual-url" | "manual_url" | "manual" => Ok(Self::ManualUrl), + other => anyhow::bail!( + "Unknown X clip discovery strategy '{other}'. Available: auto, api, guided, library, manual-url." + ), + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Api => "api", + Self::Guided => "guided", + Self::Library => "library", + Self::ManualUrl => "manual-url", + } + } +} + +#[derive(Debug, Clone)] +pub struct ClipDiscoveryOptions { + pub query: String, + pub limit: u32, + pub min_likes: u64, + pub strategy: ClipDiscoveryStrategy, + pub manual_url: Option, +} + +/// Build Twitter advanced search queries with engagement filters. +fn build_queries(query: &str, min_likes: u64) -> Vec { + let raw_queries = vec![ + format!("{query} min_faves:{min_likes} has:videos -is:retweet"), + format!( + "{query} min_faves:{} min_retweets:{} has:videos -is:retweet", + min_likes / 2, + min_likes / 10 + ), + ]; + + raw_queries + .into_iter() + .map(|sq| { + let encoded = urlencoding(&sq); + json!({ + "query": sq, + "url": format!("https://x.com/search?q={encoded}&f=video"), + "description": format!("Video search for '{query}' with engagement filter"), + }) + }) + .collect() +} + +/// Simple percent-encoding for URL query strings. +fn urlencoding(s: &str) -> String { + let mut result = String::new(); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + result.push(byte as char); + } + _ => { + result.push_str(&format!("%{byte:02X}")); + } + } + } + result +} + +fn metric_u64(metrics: &serde_json::Value, key: &str) -> u64 { + metrics.get(key).and_then(|v| v.as_u64()).unwrap_or(0) +} + +fn age_hours(created_at: Option<&str>) -> f64 { + created_at + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| { + let age = Utc::now() - value.with_timezone(&Utc); + (age.num_seconds().max(0) as f64) / 3600.0 + }) + .unwrap_or(24.0) +} + +fn clip_score(metrics: &serde_json::Value, created_at: Option<&str>) -> f64 { + let likes = metric_u64(metrics, "like_count") as f64; + let retweets = metric_u64(metrics, "retweet_count") as f64; + let replies = metric_u64(metrics, "reply_count") as f64; + let quotes = metric_u64(metrics, "quote_count") as f64; + let views = metric_u64(metrics, "impression_count").max(metric_u64(metrics, "view_count")) as f64; + let age_penalty = age_hours(created_at) * 0.08; + + let raw_score = likes.ln_1p() * 2.0 + + retweets.ln_1p() * 2.4 + + replies.ln_1p() * 1.2 + + quotes.ln_1p() * 1.8 + + views.ln_1p() * 0.75 + - age_penalty; + + (raw_score * 1000.0).round() / 1000.0 +} + +fn fallback_guided_discovery( + query: &str, + min_likes: u64, + search_urls: Vec, + reason: &str, +) -> serde_json::Value { + json!({ + "method": "guided_discovery", + "recommended": false, + "fallback_mode": true, + "query": query, + "min_likes": min_likes, + "reason": reason, + "search_urls": search_urls, + "instructions": [ + "Open one of the search URLs below in a browser or use a browser-control agent", + format!("Find tweets with video content matching '{query}'"), + "Copy the tweet URL (e.g., https://x.com/user/status/123456)", + "Import with: capcut-cli library import --type clip", + ], + "import_hint": "capcut-cli library import --type clip", + "setup_hint": "Set TWITTER_BEARER_TOKEN for official discovery and ensure a logged-in browser is available for X media import.", + "note": "This is a fallback path. The recommended strong-yes path uses authenticated X discovery plus authenticated media retrieval.", + }) +} + +fn library_candidates(query: &str, limit: u32) -> Result { + let mut assets = library::list_assets(Some("clip"))?; + assets.sort_by(|a, b| { + b.downloaded_at + .cmp(&a.downloaded_at) + .then_with(|| a.title.cmp(&b.title)) + }); + + let clips: Vec<_> = assets + .into_iter() + .take(limit as usize) + .enumerate() + .map(|(index, asset)| { + json!({ + "rank": index + 1, + "asset_id": asset.id, + "title": asset.title, + "tweet_url": asset.source_url, + "import_url": asset.source_url, + "source_path": "library", + "source_platform": asset.source_platform, + "duration_seconds": asset.duration_seconds, + "downloaded_at": asset.downloaded_at, + "ranking_score": ((limit as usize).saturating_sub(index)) as f64, + "auth_required_for_import": false, + }) + }) + .collect(); + + Ok(json!({ + "method": "library", + "recommended": true, + "query": query, + "clips": clips, + "total_found": clips.len(), + "import_hint": "Reuse an existing clip asset directly from the local library.", + "note": "This is the fastest and most reliable path when fresh X discovery is not required.", + })) +} + +fn manual_url_candidates(query: &str, manual_url: &str) -> Result { + let trimmed = manual_url.trim(); + if trimmed.is_empty() { + anyhow::bail!("manual-url strategy requires a non-empty --clip-url value."); + } + + Ok(json!({ + "method": "manual-url", + "recommended": true, + "query": query, + "clips": [{ + "rank": 1, + "tweet_url": trimmed, + "import_url": trimmed, + "source_path": "manual-url", + "ranking_score": 1.0, + "auth_required_for_import": true, + }], + "total_found": 1, + "import_hint": "Import the provided clip URL with: capcut-cli library import --type clip", + })) +} + +/// Execute a live search via Twitter API v2 if bearer token is available. +fn try_api_search(query: &str, limit: u32, min_likes: u64) -> Result> { + let bearer = std::env::var("TWITTER_BEARER_TOKEN") + .map_err(|_| TwitterDiscoveryError::AuthRequired)?; + + let search_query = format!("{query} has:videos -is:retweet"); + let max_results = limit.clamp(10, 100); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| TwitterDiscoveryError::ApiRequest { + message: e.to_string(), + })?; + + let resp = client + .get(TWITTER_SEARCH_V2) + .bearer_auth(&bearer) + .header("User-Agent", "capcut-cli/0.1.0") + .query(&[ + ("query", search_query.as_str()), + ("max_results", &max_results.to_string()), + ("tweet.fields", "attachments,created_at,public_metrics"), + ("expansions", "author_id,attachments.media_keys"), + ("media.fields", "duration_ms,preview_image_url,public_metrics,type,url"), + ("user.fields", "username,name"), + ]) + .send() + .map_err(|e| TwitterDiscoveryError::ApiRequest { + message: e.to_string(), + })?; + + if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { + return Err(TwitterDiscoveryError::RateLimited.into()); + } + if !resp.status().is_success() { + return Err(TwitterDiscoveryError::ApiStatus { + status: resp.status().as_u16(), + } + .into()); + } + + let body: serde_json::Value = resp.json().map_err(|e| TwitterDiscoveryError::ApiRequest { + message: e.to_string(), + })?; + let tweets = body + .get("data") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let mut users = std::collections::HashMap::new(); + let mut media_lookup = std::collections::HashMap::new(); + if let Some(includes) = body.get("includes") { + if let Some(user_list) = includes.get("users").and_then(|v| v.as_array()) { + for user in user_list { + if let Some(id) = user.get("id").and_then(|v| v.as_str()) { + users.insert(id.to_string(), user.clone()); + } + } + } + if let Some(media_list) = includes.get("media").and_then(|v| v.as_array()) { + for media in media_list { + if let Some(key) = media.get("media_key").and_then(|v| v.as_str()) { + media_lookup.insert(key.to_string(), media.clone()); + } + } + } + } + + let mut results: Vec<(f64, serde_json::Value)> = Vec::new(); + for tweet in tweets { + let metrics = tweet.get("public_metrics").cloned().unwrap_or(json!({})); + let likes = metric_u64(&metrics, "like_count"); + if likes < min_likes { + continue; + } + + let media_keys: Vec = tweet + .get("attachments") + .and_then(|v| v.get("media_keys")) + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|value| value.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + if media_keys.is_empty() { + continue; + } + + let video_media: Vec = media_keys + .iter() + .filter_map(|key| media_lookup.get(key).cloned()) + .filter(|media| { + matches!( + media.get("type").and_then(|v| v.as_str()), + Some("video") | Some("animated_gif") + ) + }) + .collect(); + if video_media.is_empty() { + continue; + } + + let author_id = tweet.get("author_id").and_then(|v| v.as_str()).unwrap_or(""); + let user = users.get(author_id); + let username = user + .and_then(|u| u.get("username")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let author = user + .and_then(|u| u.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or(username); + + let tweet_id = tweet.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let tweet_url = format!("https://x.com/{username}/status/{tweet_id}"); + let created_at = tweet.get("created_at").and_then(|v| v.as_str()); + let score = clip_score(&metrics, created_at); + let preview_image_url = video_media + .first() + .and_then(|media| media.get("preview_image_url")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let duration_ms = video_media + .first() + .and_then(|media| media.get("duration_ms")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let text = tweet + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .chars() + .take(200) + .collect::(); + + results.push(( + score, + json!({ + "tweet_url": tweet_url, + "import_url": tweet_url, + "text": text, + "author": author, + "username": username, + "created_at": created_at.unwrap_or(""), + "preview_image_url": preview_image_url, + "duration_ms": duration_ms, + "video_count": video_media.len(), + "ranking_score": score, + "auth_required_for_import": true, + "engagement_metrics": { + "likes": likes, + "retweets": metric_u64(&metrics, "retweet_count"), + "replies": metric_u64(&metrics, "reply_count"), + "quotes": metric_u64(&metrics, "quote_count"), + "views": metric_u64(&metrics, "impression_count").max(metric_u64(&metrics, "view_count")), + } + }), + )); + } + + results.sort_by(|a, b| { + b.0.partial_cmp(&a.0) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + b.1.get("tweet_url") + .and_then(|v| v.as_str()) + .cmp(&a.1.get("tweet_url").and_then(|v| v.as_str())) + }) + }); + + let ranked = results + .into_iter() + .enumerate() + .map(|(index, (_, mut clip))| { + if let Some(object) = clip.as_object_mut() { + object.insert("rank".to_string(), json!(index + 1)); + } + clip + }) + .collect(); + + Ok(ranked) +} + +/// Find viral video clips on X/Twitter. +pub fn find_viral_clips( + query: &str, + limit: u32, + min_likes: u64, + allow_guided_fallback: bool, +) -> Result { + output::log(&format!( + "Searching X/Twitter for viral clips: '{query}' (min_likes={min_likes})..." + )); + + let search_urls = build_queries(query, min_likes); + + let api_results = match try_api_search(query, limit, min_likes) { + Ok(results) => results, + Err(error) => { + if let Some(TwitterDiscoveryError::AuthRequired) = + error.downcast_ref::() + { + if allow_guided_fallback { + output::log( + "Reliable X discovery requires TWITTER_BEARER_TOKEN; returning guided fallback.", + ); + return Ok(fallback_guided_discovery( + query, + min_likes, + search_urls, + "TWITTER_BEARER_TOKEN not set", + )); + } + } + return Err(error); + } + }; + + if api_results.is_empty() && allow_guided_fallback { + output::log("X API returned no video candidates; returning guided fallback."); + return Ok(fallback_guided_discovery( + query, + min_likes, + search_urls, + "API search returned no results matching filters", + )); + } + + let count = api_results.len(); + output::log(&format!("Found {count} clips via Twitter API v2")); + let clipped: Vec<_> = api_results.into_iter().take(limit as usize).collect(); + Ok(json!({ + "method": "api_search", + "recommended": true, + "query": query, + "min_likes": min_likes, + "clips": clipped, + "total_found": count, + "search_urls": search_urls, + "import_hint": "Import a clip with: capcut-cli library import --type clip", + "auth": { + "discovery": "TWITTER_BEARER_TOKEN", + "import": "browser_cookies" + } + })) +} + +pub fn find_viral_clips_with_options(options: &ClipDiscoveryOptions) -> Result { + output::log(&format!( + "Searching X/Twitter clips with strategy '{}' for query '{}'...", + options.strategy.as_str(), + options.query + )); + + match options.strategy { + ClipDiscoveryStrategy::Auto => { + if let Some(url) = options.manual_url.as_deref() { + return manual_url_candidates(&options.query, url); + } + + if std::env::var("TWITTER_BEARER_TOKEN") + .ok() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + if let Ok(results) = find_viral_clips_with_options(&ClipDiscoveryOptions { + query: options.query.clone(), + limit: options.limit, + min_likes: options.min_likes, + strategy: ClipDiscoveryStrategy::Api, + manual_url: None, + }) { + return Ok(results); + } + } + + let guided = find_viral_clips_with_options(&ClipDiscoveryOptions { + query: options.query.clone(), + limit: options.limit, + min_likes: options.min_likes, + strategy: ClipDiscoveryStrategy::Guided, + manual_url: None, + })?; + + let has_urls = guided + .get("search_urls") + .and_then(|value| value.as_array()) + .map(|items| !items.is_empty()) + .unwrap_or(false); + if has_urls { + return Ok(guided); + } + + return library_candidates(&options.query, options.limit); + } + ClipDiscoveryStrategy::Api => { + find_viral_clips(&options.query, options.limit, options.min_likes, false) + } + ClipDiscoveryStrategy::Guided => { + find_viral_clips(&options.query, options.limit, options.min_likes, true) + } + ClipDiscoveryStrategy::Library => library_candidates(&options.query, options.limit), + ClipDiscoveryStrategy::ManualUrl => manual_url_candidates( + &options.query, + options.manual_url.as_deref().unwrap_or(""), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clip_score_prefers_more_engagement() { + let low = json!({ + "like_count": 100, + "retweet_count": 10, + "reply_count": 5, + "quote_count": 1, + "impression_count": 1000 + }); + let high = json!({ + "like_count": 5000, + "retweet_count": 800, + "reply_count": 200, + "quote_count": 120, + "impression_count": 80000 + }); + + assert!(clip_score(&high, Some("2026-04-12T00:00:00Z")) > clip_score(&low, Some("2026-04-12T00:00:00Z"))); + } + + #[test] + fn test_clip_score_penalizes_age() { + let metrics = json!({ + "like_count": 1000, + "retweet_count": 150, + "reply_count": 70, + "quote_count": 20, + "impression_count": 20000 + }); + + assert!(clip_score(&metrics, Some("2026-04-12T23:00:00Z")) > clip_score(&metrics, Some("2026-04-01T00:00:00Z"))); + } + + #[test] + fn test_fallback_guided_discovery_is_explicitly_not_recommended() { + let payload = fallback_guided_discovery( + "ai agents", + 1000, + build_queries("ai agents", 1000), + "TWITTER_BEARER_TOKEN not set", + ); + + assert_eq!(payload.get("recommended").and_then(|v| v.as_bool()), Some(false)); + assert_eq!(payload.get("fallback_mode").and_then(|v| v.as_bool()), Some(true)); + } + + #[test] + fn test_strategy_parse_accepts_aliases() { + assert_eq!( + ClipDiscoveryStrategy::parse("x-api").unwrap(), + ClipDiscoveryStrategy::Api + ); + assert_eq!( + ClipDiscoveryStrategy::parse("browser").unwrap(), + ClipDiscoveryStrategy::Guided + ); + assert_eq!( + ClipDiscoveryStrategy::parse("manual").unwrap(), + ClipDiscoveryStrategy::ManualUrl + ); + } + + #[test] + fn test_strategy_parse_rejects_unknown_values() { + let error = ClipDiscoveryStrategy::parse("totally-unknown").unwrap_err(); + + assert!( + error + .to_string() + .contains("Unknown X clip discovery strategy") + ); + } + + #[test] + fn test_manual_url_candidates_use_manual_source() { + let payload = manual_url_candidates("ai agents", "https://x.com/openai/status/123").unwrap(); + let clip = payload + .get("clips") + .and_then(|value| value.as_array()) + .and_then(|items| items.first()) + .cloned() + .unwrap(); + + assert_eq!(clip.get("source_path").and_then(|v| v.as_str()), Some("manual-url")); + assert_eq!( + clip.get("import_url").and_then(|v| v.as_str()), + Some("https://x.com/openai/status/123") + ); + } + + #[test] + fn test_find_viral_clips_with_options_manual_url_returns_manual_method() { + let payload = find_viral_clips_with_options(&ClipDiscoveryOptions { + query: "ai agents".to_string(), + limit: 3, + min_likes: 1000, + strategy: ClipDiscoveryStrategy::ManualUrl, + manual_url: Some("https://x.com/openai/status/123".to_string()), + }) + .unwrap(); + + assert_eq!(payload.get("method").and_then(|v| v.as_str()), Some("manual-url")); + assert_eq!(payload.get("recommended").and_then(|v| v.as_bool()), Some(true)); + } + + #[test] + fn test_library_candidates_return_existing_assets_when_available() { + let payload = library_candidates("ai agents", 3).unwrap(); + assert_eq!(payload.get("method").and_then(|v| v.as_str()), Some("library")); + assert!(payload.get("clips").and_then(|v| v.as_array()).is_some()); + } +} diff --git a/src/library.rs b/src/library.rs new file mode 100644 index 0000000..bc03966 --- /dev/null +++ b/src/library.rs @@ -0,0 +1,256 @@ +use anyhow::{Result, bail}; +use chrono::Utc; + + +use crate::config::{clips_dir, manifest_path, sounds_dir}; +use crate::media::downloader; +use crate::models::{Asset, Manifest}; +use crate::output; + +fn gen_id(asset_type: &str) -> String { + let prefix = if asset_type == "sound" { "snd" } else { "clp" }; + let hex = &uuid::Uuid::new_v4().to_string().replace('-', "")[..8]; + format!("{prefix}_{hex}") +} + +fn read_manifest() -> Manifest { + let path = manifest_path(); + if path.exists() { + if let Ok(data) = std::fs::read_to_string(&path) { + if let Ok(m) = serde_json::from_str::(&data) { + return m; + } + } + } + Manifest::default() +} + +fn write_manifest(manifest: &Manifest) -> Result<()> { + let path = manifest_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(manifest)?; + std::fs::write(path, json)?; + Ok(()) +} + +fn sanitize_source_url_for_storage(url: &str) -> String { + let Some((base, query)) = url.split_once('?') else { + return url.to_string(); + }; + + let mut kept = Vec::new(); + for pair in query.split('&') { + let key = pair.split('=').next().unwrap_or("").to_ascii_lowercase(); + let sensitive = [ + "token", + "access_token", + "refresh_token", + "authorization", + "signature", + "sig", + "x-signature", + "x-amz-signature", + "cookie", + "cookies", + ] + .iter() + .any(|candidate| key.contains(candidate)); + + if !sensitive && !pair.trim().is_empty() { + kept.push(pair.to_string()); + } + } + + if kept.is_empty() { + base.to_string() + } else { + format!("{base}?{}", kept.join("&")) + } +} + +fn preferred_title(url: &str, info: &serde_json::Value) -> String { + let raw = info + .get("title") + .and_then(|v| v.as_str()) + .or_else(|| info.get("fulltitle").and_then(|v| v.as_str())) + .unwrap_or("Untitled") + .trim() + .to_string(); + + if raw.is_empty() { + return "Untitled".to_string(); + } + + if downloader::detect_platform(url) == "tiktok" && raw.starts_with("TikTok Embed") { + if let Some(id) = info.get("id").and_then(|v| v.as_str()) { + return format!("TikTok embed {id}"); + } + } + + raw +} + +/// Download and import an asset from a URL. +pub fn import_asset(url: &str, asset_type: Option<&str>, tags: &[String]) -> Result { + let platform = downloader::detect_platform(url); + let atype = downloader::detect_asset_type(url, asset_type); + let asset_id = gen_id(atype); + + output::log(&format!("Importing {atype} from {platform}: {url}")); + + // Create asset directory + let asset_dir = if atype == "sound" { + sounds_dir().join(&asset_id) + } else { + clips_dir().join(&asset_id) + }; + std::fs::create_dir_all(&asset_dir)?; + + // Get metadata + output::log("Extracting metadata..."); + let (title, meta_duration) = match downloader::get_info(url) { + Ok(info) => ( + preferred_title(url, &info), + info.get("duration").and_then(|v| v.as_f64()).unwrap_or(0.0), + ), + Err(_) => ("Untitled".to_string(), 0.0), + }; + + // Download + output::log(&format!("Downloading {atype}...")); + let file_path = if atype == "sound" { + downloader::download_sound(url, &asset_dir)? + } else { + downloader::download_clip(url, &asset_dir)? + }; + + // Get file info + let file_size = std::fs::metadata(&file_path)?.len(); + let duration = { + let d = crate::media::ffmpeg::get_duration(&file_path.to_string_lossy()).unwrap_or(0.0); + if d > 0.0 { d } else { meta_duration } + }; + + let format = file_path + .extension() + .map(|e| e.to_string_lossy().to_string()) + .unwrap_or_default(); + + let asset = Asset { + id: asset_id.clone(), + asset_type: atype.to_string(), + title: title.clone(), + source_url: sanitize_source_url_for_storage(url), + source_platform: platform.to_string(), + downloaded_at: Utc::now().to_rfc3339(), + duration_seconds: (duration * 100.0).round() / 100.0, + file_path: file_path + .canonicalize() + .unwrap_or(file_path.clone()) + .to_string_lossy() + .to_string(), + file_size_bytes: file_size, + format, + tags: tags.to_vec(), + }; + + // Save meta.json + let meta_path = asset_dir.join("meta.json"); + std::fs::write(&meta_path, serde_json::to_string_pretty(&asset)?)?; + + // Update manifest + let mut manifest = read_manifest(); + manifest.assets.push(serde_json::to_value(&asset)?); + write_manifest(&manifest)?; + + output::log(&format!("Imported: {asset_id} ({title})")); + Ok(asset) +} + +/// List all assets, optionally filtered by type. +pub fn list_assets(asset_type: Option<&str>) -> Result> { + let manifest = read_manifest(); + let mut assets = Vec::new(); + for entry in &manifest.assets { + if let Some(filter) = asset_type { + if entry.get("type").and_then(|v| v.as_str()) != Some(filter) { + continue; + } + } + if let Ok(asset) = serde_json::from_value::(entry.clone()) { + assets.push(asset); + } + } + Ok(assets) +} + +/// Get a specific asset by ID. +pub fn get_asset(asset_id: &str) -> Result> { + let manifest = read_manifest(); + for entry in &manifest.assets { + if entry.get("id").and_then(|v| v.as_str()) == Some(asset_id) { + let asset = serde_json::from_value::(entry.clone())?; + return Ok(Some(asset)); + } + } + Ok(None) +} + +/// Delete an asset from the library. +pub fn delete_asset(asset_id: &str) -> Result<()> { + let mut manifest = read_manifest(); + let mut found = false; + let mut new_assets = Vec::new(); + + for entry in &manifest.assets { + if entry.get("id").and_then(|v| v.as_str()) == Some(asset_id) { + found = true; + // Remove asset directory + if let Some(fp) = entry.get("file_path").and_then(|v| v.as_str()) { + let path = std::path::Path::new(fp); + if let Some(parent) = path.parent() { + if parent.exists() { + let _ = std::fs::remove_dir_all(parent); + } + } + } + } else { + new_assets.push(entry.clone()); + } + } + + if !found { + bail!("Asset '{asset_id}' not found."); + } + + manifest.assets = new_assets; + write_manifest(&manifest)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_preferred_title_avoids_untitled_embed_assets() { + let info = serde_json::json!({ + "title": "TikTok Embed (1)", + "id": "7627284044752882975-1" + }); + + let title = preferred_title("https://www.tiktok.com/embed/v2/7627284044752882975", &info); + assert_eq!(title, "TikTok embed 7627284044752882975-1"); + } + + #[test] + fn test_sanitize_source_url_for_storage_strips_secretish_query_params() { + let sanitized = sanitize_source_url_for_storage( + "https://cdn.example/audio.mp3?token=abc&expires=60&x-signature=zzz", + ); + + assert_eq!(sanitized, "https://cdn.example/audio.mp3?expires=60"); + } +} diff --git a/src/main.rs b/src/main.rs index 4f8cd1a..dc96f47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,11 @@ mod cli; +mod config; +mod deps; +mod discover; +mod library; +mod media; mod models; +mod output; use clap::Parser; diff --git a/src/media/compose.rs b/src/media/compose.rs new file mode 100644 index 0000000..2c18536 --- /dev/null +++ b/src/media/compose.rs @@ -0,0 +1,263 @@ +use anyhow::{Result, bail}; +use std::path::PathBuf; + +use crate::config::{output_dir, tmp_dir, LoudnessPreset, DEFAULT_LOUDNESS, LOUDNESS_PRESETS}; +use crate::library; +use crate::media::ffmpeg; +use crate::models::ComposeResult; +use crate::output; + +/// Resolve a loudness preset name (or raw LUFS value) to its parameters. +pub fn resolve_loudness(preset: Option<&str>) -> Result { + let name = preset.unwrap_or(DEFAULT_LOUDNESS); + + if let Some(p) = LOUDNESS_PRESETS.get(name) { + return Ok(p.clone()); + } + + // Allow raw LUFS value like "-8" or "-14.0" + if let Ok(lufs) = name.parse::() { + return Ok(LoudnessPreset { + lufs, + tp: -1.0, + lra: 9.0, + label: "custom", + }); + } + + let available: Vec<_> = LOUDNESS_PRESETS.keys().collect(); + bail!( + "Unknown loudness preset '{name}'. Available: {} — or pass a numeric LUFS value (e.g. -10).", + available.iter().map(|k| k.to_string()).collect::>().join(", ") + ); +} + +/// Run the full composition pipeline. +pub fn run_compose( + sound_id: &str, + clip_ids: &[String], + duration_seconds: f64, + output_path: Option<&str>, + resolution: &str, + loudness: Option<&str>, +) -> Result { + // Parse resolution + let parts: Vec<&str> = resolution.split('x').collect(); + if parts.len() != 2 { + bail!("Invalid resolution format '{resolution}'. Expected WxH (e.g. 1080x1920)."); + } + let width: u32 = parts[0].parse().unwrap_or(0); + let height: u32 = parts[1].parse().unwrap_or(0); + if width == 0 || height == 0 { + bail!("Invalid resolution dimensions in '{resolution}'."); + } + + // Validate inputs + let sound = library::get_asset(sound_id)? + .ok_or_else(|| anyhow::anyhow!("Sound '{sound_id}' not found in library."))?; + if sound.asset_type != "sound" { + bail!("Asset '{sound_id}' is a {}, not a sound.", sound.asset_type); + } + + let mut clips = Vec::new(); + for cid in clip_ids { + let clip = library::get_asset(cid)? + .ok_or_else(|| anyhow::anyhow!("Clip '{cid}' not found in library."))?; + clips.push(clip); + } + + // Set up working directory + let job_id = &uuid::Uuid::new_v4().to_string()[..8]; + let work_dir = tmp_dir().join(format!("compose_{job_id}")); + std::fs::create_dir_all(&work_dir)?; + + let result = (|| -> Result { + // Step 1: Normalize audio to target loudness + let loud = resolve_loudness(loudness)?; + output::log(&format!( + "Step 1/5: Normalizing audio to {} LUFS ({})...", + loud.lufs, loud.label + )); + let normalized_audio = work_dir.join("audio_normalized.mp3"); + ffmpeg::normalize_audio( + &sound.file_path, + &normalized_audio.to_string_lossy(), + loud.lufs, + loud.tp, + loud.lra, + )?; + + // Step 2: Trim audio to target duration + output::log("Step 2/5: Trimming audio..."); + let trimmed_audio = work_dir.join("audio_trimmed.mp3"); + ffmpeg::trim_audio( + &normalized_audio.to_string_lossy(), + &trimmed_audio.to_string_lossy(), + duration_seconds, + )?; + + // Step 3: Trim and process each clip + output::log("Step 3/5: Processing clips..."); + let mut processed_clips = Vec::new(); + let n_clips = clips.len(); + let segment_duration = duration_seconds / n_clips as f64; + + for (i, clip) in clips.iter().enumerate() { + let mut clip_duration = clip.duration_seconds; + if clip_duration <= 0.0 { + clip_duration = ffmpeg::get_duration(&clip.file_path).unwrap_or(0.0); + } + + let trim_dur = segment_duration.min(clip_duration); + + // Trim clip (fast, stream copy) + let trimmed_path = work_dir.join(format!("clip_{i}_trimmed.mp4")); + ffmpeg::trim_media( + &clip.file_path, + &trimmed_path.to_string_lossy(), + 0.0, + trim_dur, + )?; + + // Scale and crop to target resolution + let scaled_path = work_dir.join(format!("clip_{i}_scaled.mp4")); + ffmpeg::scale_and_crop( + &trimmed_path.to_string_lossy(), + &scaled_path.to_string_lossy(), + width, + height, + )?; + processed_clips.push(scaled_path.to_string_lossy().to_string()); + } + + // Step 4: Concatenate clips + output::log("Step 4/5: Concatenating clips..."); + let concat_path = work_dir.join("concat.mp4"); + + if n_clips == 1 { + let actual_dur = ffmpeg::get_duration(&processed_clips[0]).unwrap_or(0.0); + if actual_dur < duration_seconds { + ffmpeg::loop_video( + &processed_clips[0], + &concat_path.to_string_lossy(), + duration_seconds, + )?; + } else { + std::fs::copy(&processed_clips[0], &concat_path)?; + } + } else { + ffmpeg::concat_videos(&processed_clips, &concat_path.to_string_lossy())?; + } + + // Step 5: Mux audio + video + output::log("Step 5/5: Muxing final output..."); + let final_path: PathBuf = if let Some(p) = output_path { + PathBuf::from(p) + } else { + let out_dir = output_dir().join(format!("comp_{job_id}")); + std::fs::create_dir_all(&out_dir)?; + out_dir.join("final.mp4") + }; + + ffmpeg::mux_audio_video( + &concat_path.to_string_lossy(), + &trimmed_audio.to_string_lossy(), + &final_path.to_string_lossy(), + Some(duration_seconds), + )?; + + let file_size = std::fs::metadata(&final_path)?.len(); + let actual_duration = ffmpeg::get_duration(&final_path.to_string_lossy()).unwrap_or(0.0); + + output::log(&format!( + "Composed: {} ({actual_duration:.1}s, {file_size} bytes)", + final_path.display() + )); + + Ok(ComposeResult { + output_path: final_path + .canonicalize() + .unwrap_or(final_path.clone()) + .to_string_lossy() + .to_string(), + duration_seconds: (actual_duration * 100.0).round() / 100.0, + file_size_bytes: file_size, + sound_id: sound_id.to_string(), + clip_ids: clip_ids.to_vec(), + resolution: resolution.to_string(), + }) + })(); + + // Clean up working directory + let _ = std::fs::remove_dir_all(&work_dir); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_resolve_loudness_default_is_viral() { + let preset = resolve_loudness(None).unwrap(); + assert!((preset.lufs - (-8.0)).abs() < f64::EPSILON); + } + + #[test] + fn test_resolve_loudness_named_preset() { + let preset = resolve_loudness(Some("podcast")).unwrap(); + assert!((preset.lufs - (-14.0)).abs() < f64::EPSILON); + } + + #[test] + fn test_resolve_loudness_numeric() { + let preset = resolve_loudness(Some("-12")).unwrap(); + assert!((preset.lufs - (-12.0)).abs() < f64::EPSILON); + } + + #[test] + fn test_resolve_loudness_unknown_errors() { + assert!(resolve_loudness(Some("nonexistent")).is_err()); + } + + #[test] + fn test_viral_louder_than_podcast() { + let viral = resolve_loudness(Some("viral")).unwrap(); + let podcast = resolve_loudness(Some("podcast")).unwrap(); + assert!(viral.lufs > podcast.lufs); + } + + #[test] + fn test_compose_smoke_with_existing_library_assets() { + let assets = crate::library::list_assets(None).unwrap(); + let sound = assets + .iter() + .filter(|asset| asset.asset_type == "sound") + .min_by(|a, b| a.duration_seconds.partial_cmp(&b.duration_seconds).unwrap()) + .expect("expected at least one sound asset in library manifest"); + let clip = assets + .iter() + .filter(|asset| asset.asset_type == "clip") + .min_by(|a, b| a.duration_seconds.partial_cmp(&b.duration_seconds).unwrap()) + .expect("expected at least one clip asset in library manifest"); + + let out = std::env::temp_dir().join(format!( + "capcut-cli-compose-smoke-{}.mp4", + uuid::Uuid::new_v4() + )); + let result = run_compose( + &sound.id, + &[clip.id.clone()], + 0.5, + Some(out.to_str().unwrap()), + "540x960", + Some("social"), + ) + .unwrap(); + + assert!(Path::new(&result.output_path).exists()); + let _ = std::fs::remove_file(&result.output_path); + } +} diff --git a/src/media/downloader.rs b/src/media/downloader.rs new file mode 100644 index 0000000..9e36f49 --- /dev/null +++ b/src/media/downloader.rs @@ -0,0 +1,623 @@ +use anyhow::{Context, Result, bail}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use thiserror::Error; + +use crate::config::{bin_dir, ytdlp_path}; +use crate::deps::get_ffmpeg_path; +use crate::output; + +const REDACT_KEYS: &[&str] = &[ + "token", + "access_token", + "refresh_token", + "authorization", + "signature", + "sig", + "x-signature", + "x-amz-signature", + "cookie", + "cookies", +]; + +#[derive(Debug, Error)] +pub enum DownloadError { + #[error("X/Twitter media import requires authenticated browser cookies. Tried browsers: {browsers}.")] + XAuthRequired { browsers: String }, + #[error("X/Twitter media import is rate limited.")] + XRateLimited, + #[error("Tweet {tweet_id} is suspended.")] + XSuspended { tweet_id: String }, + #[error("Tweet {tweet_id} does not contain downloadable video media.")] + XNoVideo { tweet_id: String }, + #[error("Tweet {tweet_id} has unavailable video media.")] + XVideoUnavailable { tweet_id: String }, + #[error("yt-dlp download failed: {message}")] + YtDlpFailure { message: String }, + #[error("ffmpeg audio conversion failed: {message}")] + AudioConversionFailed { message: String }, +} + +fn base_args() -> Vec { + let ffmpeg_dir = bin_dir(); + let _ = ensure_ffmpeg_symlinks(); + vec![ + "--ffmpeg-location".to_string(), + ffmpeg_dir.to_string_lossy().to_string(), + ] +} + +fn ensure_ffmpeg_symlinks() -> Result<()> { + let ffmpeg_real = get_ffmpeg_path()?; + let bin = bin_dir(); + let ffmpeg_link = bin.join("ffmpeg"); + let ffprobe_link = bin.join("ffprobe"); + + if !ffmpeg_link.exists() && ffmpeg_real != "ffmpeg" { + let _ = std::os::unix::fs::symlink(&ffmpeg_real, &ffmpeg_link); + } + if !ffprobe_link.exists() && ffmpeg_real != "ffmpeg" { + let _ = std::os::unix::fs::symlink(&ffmpeg_real, &ffprobe_link); + } + Ok(()) +} + +fn run_ytdlp_process(cmd_args: &[String]) -> Result { + let ytdlp = ytdlp_path(); + if !ytdlp.exists() { + bail!( + "yt-dlp not found at {}. Run 'capcut-cli deps install' first.", + ytdlp.display() + ); + } + + output::log(&format!("Running: yt-dlp {}", redact_command_args(cmd_args))); + + Command::new(ytdlp.to_string_lossy().as_ref()) + .args(cmd_args) + .output() + .context("Failed to run yt-dlp") +} + +fn redact_url_like(value: &str) -> String { + let Some((base, query)) = value.split_once('?') else { + return value.to_string(); + }; + + let mut redacted = Vec::new(); + for pair in query.split('&') { + let mut parts = pair.splitn(2, '='); + let key = parts.next().unwrap_or(""); + let val = parts.next().unwrap_or(""); + let lowered = key.to_ascii_lowercase(); + if REDACT_KEYS.iter().any(|candidate| lowered.contains(candidate)) { + redacted.push(format!("{key}=REDACTED")); + } else { + redacted.push(format!("{key}={val}")); + } + } + + format!("{base}?{}", redacted.join("&")) +} + +fn redact_command_args(args: &[String]) -> String { + args.iter() + .map(|arg| { + if arg.starts_with("http://") || arg.starts_with("https://") { + redact_url_like(arg) + } else { + arg.clone() + } + }) + .collect::>() + .join(" ") +} + +fn sanitize_error_text(message: &str) -> String { + message + .split_whitespace() + .map(|part| { + if part.starts_with("http://") || part.starts_with("https://") { + redact_url_like(part) + } else { + part.to_string() + } + }) + .collect::>() + .join(" ") +} + +fn cookie_browsers() -> Vec { + let configured = std::env::var("CAPCUT_X_COOKIE_BROWSERS") + .or_else(|_| std::env::var("CAPCUT_COOKIE_BROWSERS")) + .unwrap_or_else(|_| "chrome,safari,firefox,edge".to_string()); + + let mut browsers = Vec::new(); + for browser in configured.split(',') { + let browser = browser.trim(); + if !browser.is_empty() && !browsers.iter().any(|item| item == browser) { + browsers.push(browser.to_string()); + } + } + browsers +} + +fn extract_tweet_id(url: &str) -> String { + url.split("/status/") + .nth(1) + .and_then(|rest| rest.split('/').next()) + .unwrap_or("unknown") + .to_string() +} + +fn classify_twitter_failure(stderr: &str, url: &str, browsers_tried: &[String]) -> DownloadError { + let lower = stderr.to_lowercase(); + let tweet_id = extract_tweet_id(url); + + if lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("http error 429") { + return DownloadError::XRateLimited; + } + if lower.contains("suspended") { + return DownloadError::XSuspended { tweet_id }; + } + if lower.contains("no video could be found in this tweet") + || lower.contains("does not contain downloadable video") + { + return DownloadError::XNoVideo { tweet_id }; + } + if lower.contains("video #") && lower.contains("unavailable") { + return DownloadError::XVideoUnavailable { tweet_id }; + } + if lower.contains("login required") + || lower.contains("authentication") + || lower.contains("cookies") + || lower.contains("not logged in") + || lower.contains("cookie") + || lower.contains("session") + || lower.contains("sign in") + { + return DownloadError::XAuthRequired { + browsers: browsers_tried.join(", "), + }; + } + + DownloadError::YtDlpFailure { + message: sanitize_error_text(stderr.trim()), + } +} + +fn parse_ytdlp_json_output(stdout: &[u8]) -> Result { + if let Ok(value) = serde_json::from_slice::(stdout) { + return Ok(value); + } + + for line in String::from_utf8_lossy(stdout).lines() { + let line = line.trim(); + if line.is_empty() || !line.starts_with('{') { + continue; + } + if let Ok(value) = serde_json::from_str::(line) { + return Ok(value); + } + } + + bail!("Failed to parse yt-dlp JSON output") +} + +fn run_ytdlp(url: &str, args: &[&str]) -> Result { + if detect_platform(url) == "twitter" { + return run_ytdlp_twitter(url, args); + } + + let mut cmd_args = base_args(); + for arg in args { + cmd_args.push(arg.to_string()); + } + + let result = run_ytdlp_process(&cmd_args)?; + if !result.status.success() { + let stderr = String::from_utf8_lossy(&result.stderr).to_lowercase(); + if stderr.contains("blocked") { + for browser in cookie_browsers() { + let mut retry_args = base_args(); + retry_args.push("--cookies-from-browser".to_string()); + retry_args.push(browser.clone()); + for arg in args { + retry_args.push(arg.to_string()); + } + let retry = run_ytdlp_process(&retry_args)?; + if retry.status.success() { + return Ok(retry); + } + } + } + } + + Ok(result) +} + +fn run_ytdlp_twitter(url: &str, args: &[&str]) -> Result { + let browsers = cookie_browsers(); + let mut last_error: Option = None; + let mut last_output: Option = None; + let mut tried = Vec::new(); + + for browser in &browsers { + tried.push(browser.clone()); + let mut cmd_args = base_args(); + cmd_args.push("--cookies-from-browser".to_string()); + cmd_args.push(browser.clone()); + for arg in args { + cmd_args.push(arg.to_string()); + } + + let output = run_ytdlp_process(&cmd_args)?; + if output.status.success() { + return Ok(output); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let classified = classify_twitter_failure(&stderr, url, &tried); + match classified { + DownloadError::XAuthRequired { .. } => { + last_error = Some(classified); + last_output = Some(output); + } + _ => return Err(classified.into()), + } + } + + if let Some(error) = last_error { + return Err(error.into()); + } + if let Some(output) = last_output { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(classify_twitter_failure(&stderr, url, &browsers).into()); + } + + Err(DownloadError::XAuthRequired { + browsers: browsers.join(", "), + } + .into()) +} + +/// Extract metadata from a URL without downloading. +pub fn get_info(url: &str) -> Result { + let result = run_ytdlp(url, &["--dump-json", "--no-download", url])?; + if !result.status.success() { + let stderr = String::from_utf8_lossy(&result.stderr); + if detect_platform(url) == "twitter" { + return Err(classify_twitter_failure(&stderr, url, &cookie_browsers()).into()); + } + return Err(DownloadError::YtDlpFailure { + message: sanitize_error_text(stderr.trim()), + } + .into()); + } + + parse_ytdlp_json_output(&result.stdout) +} + +/// Detect the platform from a URL. +pub fn detect_platform(url: &str) -> &'static str { + let lower = url.to_lowercase(); + if lower.contains("tiktok.com") { + "tiktok" + } else if lower.contains("x.com") || lower.contains("twitter.com") { + "twitter" + } else if lower.contains("youtube.com") || lower.contains("youtu.be") { + "youtube" + } else if lower.contains("instagram.com") { + "instagram" + } else { + "unknown" + } +} + +/// Detect whether a URL is a sound or clip. +pub fn detect_asset_type(url: &str, explicit: Option<&str>) -> &'static str { + if let Some(t) = explicit { + if t == "sound" { + return "sound"; + } + return "clip"; + } + let lower = url.to_lowercase(); + if detect_platform(url) == "tiktok" && lower.contains("/music/") { + return "sound"; + } + "clip" +} + +/// Download audio from a URL, extract as mp3. +pub fn download_sound(url: &str, output_dir: &Path) -> Result { + let raw_template = output_dir.join("raw_audio.%(ext)s"); + let result = run_ytdlp( + url, + &[ + "-f", + "bestaudio/best", + "-o", + &raw_template.to_string_lossy(), + "--no-playlist", + url, + ], + )?; + if !result.status.success() { + let stderr = String::from_utf8_lossy(&result.stderr); + if detect_platform(url) == "twitter" { + return Err(classify_twitter_failure(&stderr, url, &cookie_browsers()).into()); + } + return Err(DownloadError::YtDlpFailure { + message: sanitize_error_text(stderr.trim()), + } + .into()); + } + + let raw_path = find_file_matching(output_dir, "raw_audio.")?; + let mp3_path = output_dir.join("audio.mp3"); + let ffmpeg = get_ffmpeg_path()?; + let conv = Command::new(&ffmpeg) + .args([ + "-i", + &raw_path.to_string_lossy(), + "-vn", + "-acodec", + "libmp3lame", + "-q:a", + "0", + &mp3_path.to_string_lossy(), + "-y", + ]) + .output() + .context("Failed to run ffmpeg for audio conversion")?; + + if !conv.status.success() { + let stderr = String::from_utf8_lossy(&conv.stderr); + return Err(DownloadError::AudioConversionFailed { + message: sanitize_error_text(stderr.trim()), + } + .into()); + } + + let _ = std::fs::remove_file(&raw_path); + Ok(mp3_path) +} + +/// Download video from a URL as mp4. +pub fn download_clip(url: &str, output_dir: &Path) -> Result { + let output_template = output_dir.join("video.%(ext)s"); + let result = run_ytdlp( + url, + &[ + "-f", + "bestvideo[height<=1080]+bestaudio/best[height<=1080]/best", + "--merge-output-format", + "mp4", + "-o", + &output_template.to_string_lossy(), + "--no-playlist", + url, + ], + )?; + if !result.status.success() { + let stderr = String::from_utf8_lossy(&result.stderr); + if detect_platform(url) == "twitter" { + return Err(classify_twitter_failure(&stderr, url, &cookie_browsers()).into()); + } + return Err(DownloadError::YtDlpFailure { + message: sanitize_error_text(stderr.trim()), + } + .into()); + } + + find_file_matching(output_dir, "video.") +} + +fn find_file_matching(dir: &Path, prefix: &str) -> Result { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name(); + if name.to_string_lossy().starts_with(prefix) { + return Ok(entry.path()); + } + } + bail!( + "Download succeeded but no file matching '{prefix}*' found in {}", + dir.display() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ytdlp_json_output_accepts_multiline_stream() { + let stdout = br#"{"title":"TikTok Embed (1)","duration":36.2} +{"title":"TikTok Embed (2)","duration":36.2} +"#; + + let parsed = parse_ytdlp_json_output(stdout).unwrap(); + assert_eq!(parsed.get("title").and_then(|v| v.as_str()), Some("TikTok Embed (1)")); + } + + #[test] + fn test_classify_twitter_failure_auth_required() { + let err = classify_twitter_failure( + "ERROR: login required to view this content", + "https://x.com/user/status/123", + &["chrome".to_string(), "safari".to_string()], + ); + + assert!(matches!(err, DownloadError::XAuthRequired { .. })); + } + + #[test] + fn test_classify_twitter_failure_no_video() { + let err = classify_twitter_failure( + "ERROR: [twitter] 123: No video could be found in this tweet", + "https://x.com/user/status/123", + &["chrome".to_string()], + ); + + assert!(matches!(err, DownloadError::XNoVideo { .. })); + } + + #[test] + fn test_classify_twitter_failure_video_unavailable() { + let err = classify_twitter_failure( + "ERROR: [twitter] 123: Video #1 is unavailable", + "https://x.com/user/status/123/video/1", + &["chrome".to_string()], + ); + + assert!(matches!(err, DownloadError::XVideoUnavailable { .. })); + } + + #[test] + fn test_redact_url_like_hides_tokenish_query_values() { + let redacted = redact_url_like( + "https://example.com/video.mp4?token=abc123&x-signature=zzz&expires=60", + ); + + assert!(redacted.contains("token=REDACTED")); + assert!(redacted.contains("x-signature=REDACTED")); + assert!(redacted.contains("expires=60")); + assert!(!redacted.contains("abc123")); + } + + #[test] + fn test_sanitize_error_text_redacts_signed_urls() { + let message = + "ERROR: request failed for https://example.com/a.mp4?refresh_token=abc&expires=1"; + let redacted = sanitize_error_text(message); + assert!(redacted.contains("refresh_token=REDACTED")); + assert!(!redacted.contains("refresh_token=abc")); + } + + #[test] + fn test_detect_platform_recognizes_manual_url_sources() { + assert_eq!( + detect_platform("https://www.youtube.com/watch?v=abc123"), + "youtube" + ); + assert_eq!( + detect_platform("https://x.com/openai/status/123"), + "twitter" + ); + } + + #[test] + fn test_detect_asset_type_respects_explicit_sound_for_manual_urls() { + assert_eq!( + detect_asset_type("https://www.youtube.com/watch?v=abc123", Some("sound")), + "sound" + ); + assert_eq!( + detect_asset_type("https://www.youtube.com/watch?v=abc123", Some("clip")), + "clip" + ); + } +} + +#[cfg(test)] +mod tests_url_detection { + use super::*; + + // ── detect_platform ──────────────────────────────────────────── + + #[test] + fn detect_platform_tiktok() { + assert_eq!(detect_platform("https://www.tiktok.com/@user/video/123"), "tiktok"); + } + + #[test] + fn detect_platform_twitter_x() { + assert_eq!(detect_platform("https://x.com/user/status/123"), "twitter"); + assert_eq!(detect_platform("https://twitter.com/user/status/456"), "twitter"); + } + + #[test] + fn detect_platform_youtube() { + assert_eq!(detect_platform("https://www.youtube.com/watch?v=abc"), "youtube"); + assert_eq!(detect_platform("https://youtu.be/abc"), "youtube"); + } + + #[test] + fn detect_platform_instagram() { + assert_eq!(detect_platform("https://www.instagram.com/reel/abc"), "instagram"); + } + + #[test] + fn detect_platform_unknown() { + assert_eq!(detect_platform("https://vimeo.com/123"), "unknown"); + } + + #[test] + fn detect_platform_case_insensitive() { + assert_eq!(detect_platform("https://WWW.TIKTOK.COM/video"), "tiktok"); + assert_eq!(detect_platform("https://YOUTUBE.COM/watch"), "youtube"); + } + + // ── detect_asset_type ────────────────────────────────────────── + + #[test] + fn detect_asset_type_explicit_sound() { + assert_eq!(detect_asset_type("https://youtube.com/watch?v=x", Some("sound")), "sound"); + } + + #[test] + fn detect_asset_type_explicit_clip() { + assert_eq!(detect_asset_type("https://youtube.com/watch?v=x", Some("clip")), "clip"); + } + + #[test] + fn detect_asset_type_explicit_overrides_url() { + // Even a TikTok music URL should return "clip" if explicit type says so + assert_eq!( + detect_asset_type("https://www.tiktok.com/music/something-123", Some("clip")), + "clip" + ); + } + + #[test] + fn detect_asset_type_tiktok_music_auto() { + assert_eq!( + detect_asset_type("https://www.tiktok.com/music/trending-song-123", None), + "sound" + ); + } + + #[test] + fn detect_asset_type_defaults_to_clip() { + assert_eq!(detect_asset_type("https://youtube.com/watch?v=x", None), "clip"); + assert_eq!(detect_asset_type("https://x.com/user/status/123", None), "clip"); + } + + // ── find_file_matching ───────────────────────────────────────── + + #[test] + fn find_file_matching_finds_prefixed_file() { + let dir = std::env::temp_dir().join("capcut_test_find"); + let _ = std::fs::create_dir_all(&dir); + let test_file = dir.join("raw_audio.webm"); + std::fs::write(&test_file, "test").unwrap(); + + let found = find_file_matching(&dir, "raw_audio.").unwrap(); + assert!(found.to_string_lossy().contains("raw_audio.")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn find_file_matching_errors_when_missing() { + let dir = std::env::temp_dir().join("capcut_test_find_empty"); + let _ = std::fs::create_dir_all(&dir); + + let result = find_file_matching(&dir, "nonexistent."); + assert!(result.is_err()); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/media/ffmpeg.rs b/src/media/ffmpeg.rs new file mode 100644 index 0000000..f68a635 --- /dev/null +++ b/src/media/ffmpeg.rs @@ -0,0 +1,202 @@ +use anyhow::{Context, Result, bail}; +use std::path::Path; +use std::process::Command; + +use crate::deps::get_ffmpeg_path; +use crate::output; + +fn run_ffmpeg(args: &[&str], _timeout_secs: u64) -> Result { + let ffmpeg = get_ffmpeg_path()?; + let display_args: Vec<_> = args.iter().rev().take(6).rev().collect(); + output::log(&format!("ffmpeg: {}", display_args.iter().map(|a| a.to_string()).collect::>().join(" "))); + + let child = Command::new(&ffmpeg) + .args(args) + .output() + .context("Failed to run ffmpeg")?; + + if !child.status.success() { + let stderr = String::from_utf8_lossy(&child.stderr); + let tail: String = stderr.chars().rev().take(500).collect::().chars().rev().collect(); + bail!("ffmpeg failed: {tail}"); + } + Ok(String::from_utf8_lossy(&child.stderr).to_string()) +} + +/// Get media duration in seconds. +pub fn get_duration(file_path: &str) -> Result { + let ffmpeg = get_ffmpeg_path()?; + let out = Command::new(&ffmpeg) + .args(["-i", file_path, "-f", "null", "-"]) + .output() + .context("Failed to probe duration")?; + + let stderr = String::from_utf8_lossy(&out.stderr); + for line in stderr.lines() { + if line.contains("Duration:") { + if let Some(dur_str) = line.split("Duration:").nth(1) { + let parts = dur_str.split(',').next().unwrap_or("").trim(); + if parts == "N/A" { + return Ok(0.0); + } + let segs: Vec<&str> = parts.split(':').collect(); + if segs.len() == 3 { + let h: f64 = segs[0].parse().unwrap_or(0.0); + let m: f64 = segs[1].parse().unwrap_or(0.0); + let s: f64 = segs[2].parse().unwrap_or(0.0); + return Ok(h * 3600.0 + m * 60.0 + s); + } + } + } + } + Ok(0.0) +} + +/// Loudness-normalize audio using loudnorm filter. +pub fn normalize_audio( + input_path: &str, + output_path: &str, + target_lufs: f64, + true_peak: f64, + loudness_range: f64, +) -> Result<()> { + let af = format!("loudnorm=I={target_lufs}:TP={true_peak}:LRA={loudness_range}"); + run_ffmpeg( + &["-i", input_path, "-af", &af, "-ar", "44100", "-y", output_path], + 300, + )?; + Ok(()) +} + +/// Trim media to a segment using stream copy (fast). +pub fn trim_media(input_path: &str, output_path: &str, start: f64, duration: f64) -> Result<()> { + let start_s = start.to_string(); + let dur_s = duration.to_string(); + run_ffmpeg( + &["-ss", &start_s, "-i", input_path, "-t", &dur_s, "-c", "copy", "-y", output_path], + 300, + )?; + Ok(()) +} + +/// Trim audio to a specific duration. +pub fn trim_audio(input_path: &str, output_path: &str, duration: f64) -> Result<()> { + let dur_s = duration.to_string(); + run_ffmpeg( + &["-i", input_path, "-t", &dur_s, "-acodec", "libmp3lame", "-y", output_path], + 300, + )?; + Ok(()) +} + +/// Scale and center-crop video to exact dimensions. +pub fn scale_and_crop(input_path: &str, output_path: &str, width: u32, height: u32) -> Result<()> { + let vf = format!( + "scale={width}:{height}:force_original_aspect_ratio=increase,crop={width}:{height}" + ); + run_ffmpeg( + &[ + "-i", input_path, + "-vf", &vf, + "-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-an", + "-y", output_path, + ], + 300, + )?; + Ok(()) +} + +/// Concatenate video files using the concat demuxer. +pub fn concat_videos(input_paths: &[String], output_path: &str) -> Result<()> { + if input_paths.len() == 1 { + std::fs::copy(&input_paths[0], output_path)?; + return Ok(()); + } + + let out_dir = Path::new(output_path).parent().unwrap(); + let concat_file = out_dir.join("concat_list.txt"); + let contents: String = input_paths + .iter() + .map(|p| format!("file '{p}'")) + .collect::>() + .join("\n"); + std::fs::write(&concat_file, &contents)?; + + run_ffmpeg( + &[ + "-f", "concat", + "-safe", "0", + "-i", &concat_file.to_string_lossy(), + "-c", "copy", + "-y", output_path, + ], + 300, + )?; + let _ = std::fs::remove_file(&concat_file); + Ok(()) +} + +/// Combine video and audio into final output. +pub fn mux_audio_video( + video_path: &str, + audio_path: &str, + output_path: &str, + duration: Option, +) -> Result<()> { + let mut args = vec![ + "-i", video_path, + "-i", audio_path, + "-c:v", "copy", + "-c:a", "aac", + "-b:a", "192k", + "-map", "0:v:0", + "-map", "1:a:0", + "-shortest", + ]; + let dur_s; + if let Some(d) = duration { + dur_s = d.to_string(); + args.push("-t"); + args.push(&dur_s); + } + args.push("-y"); + args.push(output_path); + run_ffmpeg(&args, 300)?; + Ok(()) +} + +/// Loop a video to fill a target duration. +pub fn loop_video(input_path: &str, output_path: &str, duration: f64) -> Result<()> { + let dur_s = duration.to_string(); + run_ffmpeg( + &[ + "-stream_loop", "-1", + "-i", input_path, + "-t", &dur_s, + "-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-an", + "-y", output_path, + ], + 300, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_parse_duration_format() { + // Simulate ffmpeg duration line parsing + let line = " Duration: 00:01:30.50, start: 0.000000, bitrate: 128 kb/s"; + assert!(line.contains("Duration:")); + let dur_str = line.split("Duration:").nth(1).unwrap(); + let parts = dur_str.split(',').next().unwrap().trim(); + let segs: Vec<&str> = parts.split(':').collect(); + let h: f64 = segs[0].parse().unwrap(); + let m: f64 = segs[1].parse().unwrap(); + let s: f64 = segs[2].parse().unwrap(); + let total = h * 3600.0 + m * 60.0 + s; + assert!((total - 90.5).abs() < 0.01); + } +} diff --git a/src/media/mod.rs b/src/media/mod.rs new file mode 100644 index 0000000..9196a4b --- /dev/null +++ b/src/media/mod.rs @@ -0,0 +1,3 @@ +pub mod compose; +pub mod downloader; +pub mod ffmpeg; diff --git a/src/models.rs b/src/models.rs index 579bf68..7007a13 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,56 +1,144 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize)] -#[serde(tag = "report", rename_all = "snake_case")] -pub enum AppReport { - Discovery(DiscoveryReport), - Library(LibraryReport), - Media(MediaReport), +/// An imported asset (sound or clip) in the library. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Asset { + pub id: String, + #[serde(rename = "type")] + pub asset_type: String, + pub title: String, + pub source_url: String, + pub source_platform: String, + pub downloaded_at: String, + pub duration_seconds: f64, + pub file_path: String, + pub file_size_bytes: u64, + pub format: String, + #[serde(default)] + pub tags: Vec, } +/// Result of the compose pipeline. #[derive(Debug, Serialize)] -pub struct DiscoveryReport { - pub source: DiscoverSource, - pub query: Option, - pub limit: u32, - pub notes: Vec, - pub next_steps: Vec, +pub struct ComposeResult { + pub output_path: String, + pub duration_seconds: f64, + pub file_size_bytes: u64, + pub sound_id: String, + pub clip_ids: Vec, + pub resolution: String, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum DiscoverSource { - TiktokSounds, - XClips, +/// JSON manifest for the library. +#[derive(Debug, Serialize, Deserialize)] +pub struct Manifest { + pub version: u32, + pub assets: Vec, } -#[derive(Debug, Serialize)] -pub struct LibraryReport { - pub asset_type: String, - pub source: Option, - pub id: Option, - pub required_metadata: Vec, +impl Default for Manifest { + fn default() -> Self { + Self { + version: 1, + assets: vec![], + } + } } -#[derive(Debug, Serialize)] -pub struct MediaReport { - pub sound_id: String, - pub clip_ids: Vec, - pub duration_seconds: u32, - pub pipeline: Vec, -} +#[cfg(test)] +mod tests { + use super::*; -#[derive(Debug, Serialize)] -pub struct PipelineStep { - pub kind: PipelineStepKind, - pub description: String, -} + fn sample_asset() -> Asset { + Asset { + id: "snd_abc12345".to_string(), + asset_type: "sound".to_string(), + title: "Test Song".to_string(), + source_url: "https://youtube.com/watch?v=test".to_string(), + source_platform: "youtube".to_string(), + downloaded_at: "2026-04-12T00:00:00Z".to_string(), + duration_seconds: 120.5, + file_path: "/tmp/test/audio.mp3".to_string(), + file_size_bytes: 4096, + format: "mp3".to_string(), + tags: vec!["trending".to_string(), "hyperpop".to_string()], + } + } -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum PipelineStepKind { - NormalizeAudio, - TrimClips, - ScaleAndCrop, - Mux, + #[test] + fn asset_serializes_type_field_as_type() { + let asset = sample_asset(); + let json = serde_json::to_value(&asset).unwrap(); + // asset_type field should serialize as "type" due to #[serde(rename)] + assert_eq!(json["type"], "sound"); + assert!(json.get("asset_type").is_none()); + } + + #[test] + fn asset_roundtrip_json() { + let asset = sample_asset(); + let json = serde_json::to_string(&asset).unwrap(); + let restored: Asset = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.id, asset.id); + assert_eq!(restored.asset_type, asset.asset_type); + assert_eq!(restored.title, asset.title); + assert_eq!(restored.duration_seconds, asset.duration_seconds); + assert_eq!(restored.tags, asset.tags); + } + + #[test] + fn asset_deserializes_with_empty_tags_default() { + let json = r#"{ + "id": "clp_00000000", + "type": "clip", + "title": "No Tags", + "source_url": "https://example.com", + "source_platform": "unknown", + "downloaded_at": "2026-01-01T00:00:00Z", + "duration_seconds": 10.0, + "file_path": "/tmp/clip.mp4", + "file_size_bytes": 1024, + "format": "mp4" + }"#; + let asset: Asset = serde_json::from_str(json).unwrap(); + assert!(asset.tags.is_empty()); + } + + #[test] + fn compose_result_serializes() { + let result = ComposeResult { + output_path: "/tmp/output/final.mp4".to_string(), + duration_seconds: 30.0, + file_size_bytes: 1048576, + sound_id: "snd_abc12345".to_string(), + clip_ids: vec!["clp_def67890".to_string()], + resolution: "1080x1920".to_string(), + }; + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["output_path"], "/tmp/output/final.mp4"); + assert_eq!(json["duration_seconds"], 30.0); + assert_eq!(json["sound_id"], "snd_abc12345"); + assert_eq!(json["clip_ids"][0], "clp_def67890"); + assert_eq!(json["resolution"], "1080x1920"); + } + + #[test] + fn manifest_default_is_version_1_empty() { + let m = Manifest::default(); + assert_eq!(m.version, 1); + assert!(m.assets.is_empty()); + } + + #[test] + fn manifest_roundtrip_with_assets() { + let asset = sample_asset(); + let mut m = Manifest::default(); + m.assets.push(serde_json::to_value(&asset).unwrap()); + + let json = serde_json::to_string(&m).unwrap(); + let restored: Manifest = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.version, 1); + assert_eq!(restored.assets.len(), 1); + assert_eq!(restored.assets[0]["id"], "snd_abc12345"); + } } diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..506220a --- /dev/null +++ b/src/output.rs @@ -0,0 +1,131 @@ +use serde::Serialize; +use std::time::Instant; + +use crate::config::VERSION; + +/// Standard JSON envelope for all CLI output. +#[derive(Debug, Serialize)] +pub struct Envelope { + pub status: &'static str, + pub command: String, + pub data: serde_json::Value, + pub errors: Vec, + pub meta: Meta, +} + +#[derive(Debug, Serialize)] +pub struct ErrorEntry { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, +} + +#[derive(Debug, Serialize)] +pub struct Meta { + pub version: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, +} + +pub fn success(command: &str, data: serde_json::Value, start: Option) -> Envelope { + Envelope { + status: "ok", + command: command.to_string(), + data, + errors: vec![], + meta: Meta { + version: VERSION, + duration_ms: start.map(|s| s.elapsed().as_millis() as u64), + }, + } +} + +pub fn error(command: &str, code: &str, message: &str, hint: Option<&str>) -> Envelope { + Envelope { + status: "error", + command: command.to_string(), + data: serde_json::Value::Null, + errors: vec![ErrorEntry { + code: code.to_string(), + message: message.to_string(), + hint: hint.map(|h| h.to_string()), + }], + meta: Meta { + version: VERSION, + duration_ms: None, + }, + } +} + +pub fn emit(envelope: &Envelope) { + if let Ok(json) = serde_json::to_string_pretty(envelope) { + println!("{json}"); + } +} + +pub fn log(msg: &str) { + eprintln!("{msg}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn success_envelope_has_ok_status() { + let env = success("test-cmd", serde_json::json!({"key": "val"}), None); + assert_eq!(env.status, "ok"); + assert_eq!(env.command, "test-cmd"); + assert!(env.errors.is_empty()); + assert_eq!(env.data["key"], "val"); + } + + #[test] + fn success_envelope_includes_duration_when_provided() { + let start = std::time::Instant::now(); + std::thread::sleep(std::time::Duration::from_millis(5)); + let env = success("cmd", serde_json::json!(null), Some(start)); + assert!(env.meta.duration_ms.unwrap() >= 5); + } + + #[test] + fn success_envelope_omits_duration_when_none() { + let env = success("cmd", serde_json::json!(null), None); + assert!(env.meta.duration_ms.is_none()); + // Serialized JSON should not contain duration_ms + let json = serde_json::to_value(&env).unwrap(); + assert!(json["meta"].get("duration_ms").is_none()); + } + + #[test] + fn error_envelope_has_error_status_and_entries() { + let env = error("bad-cmd", "ERR_CODE", "something broke", Some("try X")); + assert_eq!(env.status, "error"); + assert_eq!(env.command, "bad-cmd"); + assert_eq!(env.data, serde_json::Value::Null); + assert_eq!(env.errors.len(), 1); + assert_eq!(env.errors[0].code, "ERR_CODE"); + assert_eq!(env.errors[0].message, "something broke"); + assert_eq!(env.errors[0].hint.as_deref(), Some("try X")); + } + + #[test] + fn error_envelope_omits_hint_when_none() { + let env = error("cmd", "CODE", "msg", None); + assert!(env.errors[0].hint.is_none()); + let json = serde_json::to_value(&env).unwrap(); + assert!(json["errors"][0].get("hint").is_none()); + } + + #[test] + fn envelope_serializes_to_valid_json() { + let env = success("library import", serde_json::json!({"id": "snd_123"}), None); + let json = serde_json::to_string_pretty(&env).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["status"], "ok"); + assert_eq!(parsed["command"], "library import"); + assert_eq!(parsed["data"]["id"], "snd_123"); + assert!(parsed["meta"]["version"].is_string()); + } +} 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