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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
target/
workspace/
*.env
.env*
!.env.example
12 changes: 12 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
53 changes: 53 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
53 changes: 53 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
154 changes: 154 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Clip>,
}

#[derive(Deserialize)]
struct GeminiResponse {
candidates: Vec<GeminiCandidate>,
}

#[derive(Deserialize)]
struct GeminiCandidate {
content: GeminiContentResponse,
}

#[derive(Deserialize)]
struct GeminiContentResponse {
parts: Vec<GeminiPartResponse>,
}

#[derive(Deserialize)]
struct GeminiPartResponse {
text: String,
}

pub async fn get_video_info(url: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
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<String, Box<dyn std::error::Error + Send + Sync>> {
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<f64, Box<dyn std::error::Error + Send + Sync>> {
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<ClipConfig, Box<dyn std::error::Error + Send + Sync>> {
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
}
Loading
Loading