diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d2b3475 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +target/ +workspace/ +*.env +.env* +!.env.example diff --git a/Cargo.toml b/Cargo.toml index 66a9d6f..6bb15e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,19 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.0", features = ["full"] } dotenv = "0.15" +# HTTP server layer +axum = "0.7" +tower-http = { version = "0.5", features = ["cors"] } +uuid = { version = "1.0", features = ["v4"] } + +[lib] +name = "auto_clipper" +path = "src/lib.rs" [[bin]] name = "auto-clipper" path = "src/main.rs" + +[[bin]] +name = "clipper-server" +path = "src/server.rs" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..049998f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# ─── Stage 1: Builder ───────────────────────────────────────────────────────── +# Gunakan official Rust image untuk compile binary. +# Pisahkan build stage agar final image tidak membawa Rust toolchain (~1GB). +FROM rust:1.92-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* + +# Copy manifest dulu untuk cache layer dependencies. +# Kalau Cargo.toml/Cargo.lock tidak berubah, layer ini di-cache dan build lebih cepat. +COPY Cargo.toml Cargo.lock ./ + +# Dummy build untuk cache dependencies +RUN mkdir src && echo "fn main() {}" > src/main.rs && echo "pub fn dummy() {}" > src/lib.rs +RUN cargo build --release --bin clipper-server 2>/dev/null || true +RUN rm -rf src + +# Copy source dan build final binary +COPY src ./src +RUN touch src/main.rs src/lib.rs src/server.rs && cargo build --release --bin clipper-server + +# ─── Stage 2: Runtime ───────────────────────────────────────────────────────── +# Image minimal — hanya binary + runtime dependencies (ffmpeg, yt-dlp, curl). +# Tidak ada Rust toolchain, tidak ada source code. +FROM debian:bookworm-slim + +# Install runtime dependencies: +# ffmpeg — video processing (clip, transcode) +# yt-dlp — YouTube video download +# curl — Gemini API calls dari dalam binary +# ca-certificates — HTTPS support +RUN apt-get update && apt-get install -y \ + ffmpeg \ + curl \ + ca-certificates \ + python3 \ + && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ + && chmod +x /usr/local/bin/yt-dlp \ + && rm -rf /var/lib/apt/lists/* + +# Copy binary dari builder stage +COPY --from=builder /app/target/release/clipper-server /usr/local/bin/clipper-server + +# Working directory untuk output files (clips hasil processing) +WORKDIR /workspace + +# Port default — bisa di-override via PORT env var +EXPOSE 8080 + +# Jalankan HTTP server +CMD ["clipper-server"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..340a86f --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,53 @@ +# docker-compose.prod.yml — Production overrides +# +# Digunakan bersama docker-compose.yml untuk production deployment. +# Contoh penggunaan: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# +# Mendukung beberapa reverse proxy — pilih salah satu sesuai setup kamu: +# +# ── Opsi A: nginx-proxy (jwilder/nginx-proxy) ───────────────────────────────── +# Set VIRTUAL_HOST dan LETSENCRYPT_HOST di environment. +# Pastikan nginx-proxy dan acme-companion berjalan di network yang sama. +# +# ── Opsi B: Traefik ─────────────────────────────────────────────────────────── +# Uncomment labels traefik di bawah. +# Pastikan Traefik berjalan dengan Docker provider enabled. +# +# ── Opsi C: Cloudflare Tunnel ───────────────────────────────────────────────── +# Tidak perlu expose port. Jalankan cloudflared di host atau container terpisah. +# cloudflared tunnel --url http://clipper-engine:8080 +# +# ── Opsi D: Manual nginx/Apache ─────────────────────────────────────────────── +# Expose port dan konfigurasi ProxyPass di nginx/Apache config host. + +services: + clipper: + # Jangan expose port langsung ke host di production — biarkan reverse proxy yang handle + ports: !reset [] + + environment: + PORT: 8080 + + # ── nginx-proxy (Opsi A) ─────────────────────────────────────────────── + # Uncomment dan isi sesuai domain kamu: + # VIRTUAL_HOST: clipper-api.yourdomain.com + # VIRTUAL_PORT: 8080 + # LETSENCRYPT_HOST: clipper-api.yourdomain.com + # LETSENCRYPT_EMAIL: admin@yourdomain.com + + # ── Traefik labels (Opsi B) ─────────────────────────────────────────────── + # Uncomment jika pakai Traefik: + # labels: + # - "traefik.enable=true" + # - "traefik.http.routers.clipper.rule=Host(`clipper-api.yourdomain.com`)" + # - "traefik.http.routers.clipper.entrypoints=websecure" + # - "traefik.http.routers.clipper.tls.certresolver=letsencrypt" + # - "traefik.http.services.clipper.loadbalancer.server.port=8080" + + networks: + - proxy # Shared network dengan reverse proxy + +networks: + proxy: + external: true # Network ini dibuat oleh nginx-proxy atau Traefik diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4553752 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +# docker-compose.yml — Clipper Engine +# +# Untuk development lokal, jalankan: +# docker compose up +# +# Untuk production dengan nginx-proxy (reverse proxy otomatis via Docker labels): +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# +# Environment variables: +# PORT — port yang di-listen server (default: 8080) +# GEMINI_API_KEY — tidak dipakai di server mode (BYOK via request body) +# +# Output clips disimpan di volume ./workspace agar persist setelah container restart. + +services: + clipper: + build: . + container_name: clipper-engine + ports: + # Host:Container — ubah host port jika 8080 sudah dipakai + - "8080:8080" + environment: + PORT: 8080 + volumes: + # Persist output clips di luar container + - ./workspace:/workspace + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..92dfee9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,154 @@ +use serde::{Deserialize, Serialize}; +use std::process::Command; +use tokio::fs; + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Clip { + pub start: String, + pub end: String, + pub output: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ClipConfig { + pub input_video: String, + pub clips: Vec, +} + +#[derive(Deserialize)] +struct GeminiResponse { + candidates: Vec, +} + +#[derive(Deserialize)] +struct GeminiCandidate { + content: GeminiContentResponse, +} + +#[derive(Deserialize)] +struct GeminiContentResponse { + parts: Vec, +} + +#[derive(Deserialize)] +struct GeminiPartResponse { + text: String, +} + +pub async fn get_video_info(url: &str) -> Result> { + let output = Command::new("yt-dlp") + .args(["--get-title", "--get-duration", url]) + .output()?; + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +pub async fn download_video(url: &str) -> Result> { + let output = Command::new("yt-dlp") + .args(["-f", "best[height<=720]", "-o", "video.%(ext)s", url]) + .output()?; + if !output.status.success() { + return Err(format!( + "Failed to download: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + Ok("video.mp4".to_string()) +} + +pub async fn get_video_duration( + file: &str, +) -> Result> { + let output = Command::new("ffprobe") + .args([ + "-v", + "quiet", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + file, + ]) + .output()?; + Ok(String::from_utf8_lossy(&output.stdout).trim().parse()?) +} + +pub async fn analyze_with_gemini( + video_info: &str, + duration: f64, + api_key: &str, +) -> Result> { + let prompt = format!( + "Analyze this YouTube video and suggest 3-5 viral-worthy clips (10-30 seconds each) \ +from a {:.0} second video titled: {}\n\n\ +Return ONLY a JSON object in this exact format:\n\ +{{\n \"input_video\": \"video.mp4\",\n \"clips\": [\n {{\n \ +\"start\": \"00:00:10\",\n \"end\": \"00:00:25\",\n \ +\"output\": \"viral_clip_1.mp4\"\n }}\n ]\n}}\n\n\ +Focus on moments that would be most engaging for social media.", + duration, + video_info.trim() + ); + + let json_payload = serde_json::json!({ + "contents": [{ "parts": [{ "text": prompt }] }] + }); + + let temp_file = format!("gemini_request_{}.json", uuid::Uuid::new_v4()); + fs::write(&temp_file, json_payload.to_string()).await?; + + let output = Command::new("curl") + .args([ + "-s", "-X", "POST", + "-H", "Content-Type: application/json", + "-d", &format!("@{}", temp_file), + &format!( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={}", + api_key + ), + ]) + .output()?; + + fs::remove_file(&temp_file).await.ok(); + + if !output.status.success() { + return Err(format!( + "Gemini API error: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + + let response_text = String::from_utf8_lossy(&output.stdout); + let gemini_response: GeminiResponse = serde_json::from_str(&response_text)?; + let text = &gemini_response.candidates[0].content.parts[0].text; + + let json_start = text.find('{').unwrap_or(0); + let json_end = text.rfind('}').unwrap_or(text.len()) + 1; + Ok(serde_json::from_str(&text[json_start..json_end])?) +} + +pub async fn process_clips(config: &ClipConfig) -> Vec<(String, bool)> { + let mut results = vec![]; + for clip in &config.clips { + let output = Command::new("ffmpeg") + .args([ + "-i", + &config.input_video, + "-ss", + &clip.start, + "-to", + &clip.end, + "-c", + "copy", + "-avoid_negative_ts", + "make_zero", + &clip.output, + "-y", + ]) + .output(); + let success = output.map(|o| o.status.success()).unwrap_or(false); + results.push((clip.output.clone(), success)); + } + results +} diff --git a/src/main.rs b/src/main.rs index e260762..4dff61d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,186 +1,58 @@ -use serde::{Deserialize, Serialize}; -use std::process::Command; -use tokio::fs; +use auto_clipper::{ + analyze_with_gemini, download_video, get_video_duration, get_video_info, process_clips, + ClipConfig, +}; use dotenv::dotenv; use std::env; - -#[derive(Deserialize, Serialize, Debug)] -struct Clip { - start: String, - end: String, - output: String, -} - -#[derive(Deserialize, Debug)] -struct ClipConfig { - input_video: String, - clips: Vec, -} - -#[derive(Deserialize)] -struct GeminiResponse { - candidates: Vec, -} - -#[derive(Deserialize)] -struct GeminiCandidate { - content: GeminiContentResponse, -} - -#[derive(Deserialize)] -struct GeminiContentResponse { - parts: Vec, -} - -#[derive(Deserialize)] -struct GeminiPartResponse { - text: String, -} - -async fn get_video_info(url: &str) -> Result> { - let output = Command::new("yt-dlp") - .args(["--get-title", "--get-duration", url]) - .output()?; - - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -async fn analyze_with_gemini(video_info: &str, duration: f64) -> Result> { - dotenv().ok(); - let api_key = env::var("GEMINI_API_KEY")?; - - let prompt = format!( - "Analyze this YouTube video and suggest 3-5 viral-worthy clips (10-30 seconds each) from a {:.0} second video titled: {} - -Return ONLY a JSON object in this exact format: -{{ - \"input_video\": \"video.mp4\", - \"clips\": [ - {{ - \"start\": \"00:00:10\", - \"end\": \"00:00:25\", - \"output\": \"viral_clip_1.mp4\" - }} - ] -}} - -Focus on moments that would be most engaging for social media.", - duration, video_info.trim() - ); - - let json_payload = serde_json::json!({ - "contents": [{ - "parts": [{ - "text": prompt - }] - }] - }); - - let temp_file = "gemini_request.json"; - fs::write(temp_file, json_payload.to_string()).await?; - - let output = Command::new("curl") - .args([ - "-X", "POST", - "-H", "Content-Type: application/json", - "-d", &format!("@{}", temp_file), - &format!("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={}", api_key) - ]) - .output()?; - - fs::remove_file(temp_file).await.ok(); - - if !output.status.success() { - return Err(format!("Gemini API error: {}", String::from_utf8_lossy(&output.stderr)).into()); - } - - let response_text = String::from_utf8_lossy(&output.stdout); - let gemini_response: GeminiResponse = serde_json::from_str(&response_text)?; - Ok(gemini_response.candidates[0].content.parts[0].text.clone()) -} - -async fn download_video(url: &str) -> Result> { - let output = Command::new("yt-dlp") - .args(["-f", "best[height<=720]", "-o", "video.%(ext)s", url]) - .output()?; - - if !output.status.success() { - return Err(format!("Failed to download video: {}", String::from_utf8_lossy(&output.stderr)).into()); - } - - Ok("video.mp4".to_string()) -} - -async fn get_video_duration(file: &str) -> Result> { - let output = Command::new("ffprobe") - .args(["-v", "quiet", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", file]) - .output()?; - - let duration_str = String::from_utf8_lossy(&output.stdout); - Ok(duration_str.trim().parse()?) -} +use tokio::fs; #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> Result<(), Box> { + dotenv().ok(); let args: Vec = std::env::args().collect(); - + if args.len() < 2 { eprintln!("Usage: {} ", args[0]); std::process::exit(1); } let input = &args[1]; - + let config: ClipConfig = if input.starts_with("http") { - // YouTube URL mode println!("🔍 Analyzing YouTube video..."); let video_info = get_video_info(input).await?; - + println!("📥 Downloading video..."); let video_file = download_video(input).await?; - + let duration = get_video_duration(&video_file).await?; println!("⏱️ Video duration: {:.1}s", duration); - + println!("🤖 Getting AI analysis..."); - let gemini_response = analyze_with_gemini(&video_info, duration).await?; - - // Extract JSON from response - let json_start = gemini_response.find('{').unwrap_or(0); - let json_end = gemini_response.rfind('}').unwrap_or(gemini_response.len()) + 1; - let json_str = &gemini_response[json_start..json_end]; - + let api_key = env::var("GEMINI_API_KEY")?; + let config = analyze_with_gemini(&video_info, duration, &api_key).await?; + println!("📝 AI suggested clips:"); - println!("{}", json_str); - - serde_json::from_str(json_str)? + println!("{}", serde_json::to_string_pretty(&config)?); + + config } else { - // Config file mode let config_content = fs::read_to_string(input).await?; serde_json::from_str(&config_content)? }; - println!("🎬 Processing {} clips from {}", config.clips.len(), config.input_video); - - for (i, clip) in config.clips.iter().enumerate() { - println!("Creating clip {}: {} -> {}", i + 1, clip.start, clip.end); - - let output = Command::new("ffmpeg") - .args([ - "-i", &config.input_video, - "-ss", &clip.start, - "-to", &clip.end, - "-c", "copy", - "-avoid_negative_ts", "make_zero", - &clip.output, - "-y" - ]) - .output()?; + println!( + "🎬 Processing {} clips from {}", + config.clips.len(), + config.input_video + ); - if output.status.success() { - println!("✓ Created: {}", clip.output); + let results = process_clips(&config).await; + for (output, success) in results { + if success { + println!("✓ Created: {}", output); } else { - eprintln!("✗ Failed to create {}: {}", clip.output, String::from_utf8_lossy(&output.stderr)); + eprintln!("✗ Failed: {}", output); } } diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..37f12ec --- /dev/null +++ b/src/server.rs @@ -0,0 +1,131 @@ +//! HTTP server for auto-clipper. +//! +//! Exposes the CLI functionality as a REST API so it can be consumed +//! by web services (e.g. pocat-api, ytmod-api) without spawning a subprocess. +//! +//! Usage: +//! PORT=8080 GEMINI_API_KEY=... clipper-server +//! +//! Endpoints: +//! GET /health +//! POST /analyze { "url": "https://youtube.com/...", "gemini_api_key": "..." } +//! POST /clip { "config": { "input_video": "...", "clips": [...] } } + +use axum::{ + extract::Json, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Router, +}; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use tower_http::cors::CorsLayer; + +use auto_clipper::{ + analyze_with_gemini, download_video, get_video_duration, get_video_info, process_clips, + ClipConfig, +}; + +// ── Request / Response types ────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct AnalyzeRequest { + url: String, + /// BYOK — caller supplies their own Gemini API key + gemini_api_key: String, +} + +#[derive(Serialize)] +struct ClipResult { + output: String, + success: bool, +} + +#[derive(Deserialize)] +struct ClipRequest { + config: ClipConfig, +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +async fn health() -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "status": "ok" }))) +} + +async fn analyze(Json(req): Json) -> impl IntoResponse { + let info = match get_video_info(&req.url).await { + Ok(v) => v, + Err(e) => { + return ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": e.to_string() })), + ) + } + }; + + let video_file = match download_video(&req.url).await { + Ok(v) => v, + Err(e) => { + return ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": e.to_string() })), + ) + } + }; + + let duration = match get_video_duration(&video_file).await { + Ok(v) => v, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + } + }; + + match analyze_with_gemini(&info, duration, &req.gemini_api_key).await { + Ok(config) => ( + StatusCode::OK, + Json(serde_json::json!({ "config": config })), + ), + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": e.to_string() })), + ), + } +} + +async fn clip(Json(req): Json) -> impl IntoResponse { + let results = process_clips(&req.config).await; + let results: Vec = results + .into_iter() + .map(|(output, success)| ClipResult { output, success }) + .collect(); + ( + StatusCode::OK, + Json(serde_json::json!({ "results": results })), + ) +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() { + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "8080".to_string()) + .parse() + .expect("PORT must be a number"); + + let app = Router::new() + .route("/health", get(health)) + .route("/analyze", post(analyze)) + .route("/clip", post(clip)) + .layer(CorsLayer::permissive()); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + println!("🚀 clipper-server listening on http://{}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +}