Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import html | |
| import json | |
| import logging | |
| import os | |
| import queue | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import threading | |
| import time | |
| import traceback | |
| import uuid | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Annotated | |
| def _bootstrap_local_paths() -> None: | |
| repo_root = Path(__file__).resolve().parent | |
| for candidate in (repo_root / "src", repo_root / "humeo-core" / "src"): | |
| candidate_str = str(candidate) | |
| if candidate.is_dir() and candidate_str not in sys.path: | |
| sys.path.insert(0, candidate_str) | |
| _bootstrap_local_paths() | |
| if not (os.environ.get("HUMEO_TRANSCRIBE_PROVIDER") or "").strip(): | |
| os.environ["HUMEO_TRANSCRIBE_PROVIDER"] = ( | |
| "elevenlabs" if (os.environ.get("ELEVENLABS_API_KEY") or "").strip() else "openai" | |
| ) | |
| from fastapi import FastAPI, File, Form, HTTPException, UploadFile | |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse | |
| from humeo.config import PipelineConfig | |
| from humeo.pipeline import run_pipeline | |
| APP_TITLE = "Humeo - long to shorts" | |
| LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s" | |
| MAX_LOG_LINES = 700 | |
| LLM_KEY_NAMES = ("GOOGLE_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY") | |
| class QueueLogHandler(logging.Handler): | |
| def __init__(self, sink: queue.Queue[str]): | |
| super().__init__() | |
| self._sink = sink | |
| def emit(self, record: logging.LogRecord) -> None: | |
| try: | |
| self._sink.put_nowait(self.format(record)) | |
| except Exception: | |
| pass | |
| class ClipFile: | |
| name: str | |
| url: str | |
| duration: str | |
| poster_url: str | None = None | |
| class Job: | |
| id: str | |
| run_root: Path | |
| output_dir: Path | |
| work_dir: Path | |
| source: str | |
| source_path: Path | None = None | |
| steering_note: str | None = None | |
| status: str = "Queued" | |
| nav_status: str = "Processing..." | |
| error: str | None = None | |
| done: bool = False | |
| created_at: float = field(default_factory=time.time) | |
| logs: list[str] = field(default_factory=list) | |
| clips: dict[str, ClipFile] = field(default_factory=dict) | |
| steps: list[dict[str, object]] = field( | |
| default_factory=lambda: [ | |
| {"name": "Uploading video", "pct": 100, "state": "done"}, | |
| {"name": "Generating transcript", "pct": 5, "state": "active"}, | |
| {"name": "Choosing short clips", "pct": 0, "state": "pending"}, | |
| {"name": "Producing clips", "pct": 0, "state": "pending"}, | |
| {"name": "Adding subtitles & light edits", "pct": 0, "state": "pending"}, | |
| ] | |
| ) | |
| JOBS: dict[str, Job] = {} | |
| JOBS_LOCK = threading.Lock() | |
| def _append_log(job: Job, line: str) -> None: | |
| job.logs.append(line) | |
| if len(job.logs) > MAX_LOG_LINES: | |
| job.logs = job.logs[-MAX_LOG_LINES:] | |
| def _set_step(job: Job, idx: int, pct: int, state: str = "active") -> None: | |
| for step_idx, step in enumerate(job.steps): | |
| if step_idx < idx: | |
| step["pct"] = 100 | |
| step["state"] = "done" | |
| elif step_idx == idx: | |
| step["pct"] = max(int(step.get("pct", 0)), min(100, pct)) | |
| step["state"] = state | |
| elif step.get("state") != "done": | |
| step["state"] = "pending" | |
| def _update_stage_from_log(job: Job, line: str) -> None: | |
| if "STAGE 1: INGESTION" in line: | |
| job.status = "Generating transcript" | |
| _set_step(job, 1, 15) | |
| elif "Transcribing" in line: | |
| job.status = "Generating transcript" | |
| _set_step(job, 1, 45) | |
| elif "Transcript already exists" in line or "Transcription complete" in line: | |
| _set_step(job, 1, 90) | |
| elif "STAGE 2: CLIP SELECTION" in line: | |
| job.status = "Choosing short clips" | |
| _set_step(job, 2, 20) | |
| elif "STAGE 2.25: HOOK DETECTION" in line: | |
| job.status = "Finding hooks" | |
| _set_step(job, 2, 55) | |
| elif "STAGE 2.5: CONTENT PRUNING" in line: | |
| job.status = "Tightening clip windows" | |
| _set_step(job, 2, 78) | |
| elif "STAGE 2.75: CLIP ASSEMBLY" in line: | |
| job.status = "Assembling clips" | |
| _set_step(job, 3, 18) | |
| elif "STAGE 3: CLIP LAYOUTS" in line: | |
| job.status = "Choosing layout" | |
| _set_step(job, 3, 38) | |
| elif "STAGE 4: RENDER" in line: | |
| job.status = "Producing clips" | |
| _set_step(job, 3, 62) | |
| elif "reframe_clip_ffmpeg" in line: | |
| _set_step(job, 4, min(90, 20 + len(job.clips) * 12)) | |
| elif "RENDER QA" in line or "Render QA summary" in line: | |
| job.status = "Checking clips" | |
| _set_step(job, 4, 82) | |
| elif "PIPELINE COMPLETE" in line: | |
| job.status = "Complete" | |
| job.nav_status = "Done" | |
| for step in job.steps: | |
| step["pct"] = 100 | |
| step["state"] = "done" | |
| def _install_log_handler(message_queue: queue.Queue[str]) -> tuple[logging.Handler, int, dict[str, int]]: | |
| handler = QueueLogHandler(message_queue) | |
| handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt="%H:%M:%S")) | |
| root_logger = logging.getLogger() | |
| previous_level = root_logger.level | |
| root_logger.addHandler(handler) | |
| root_logger.setLevel(logging.INFO) | |
| previous_logger_levels: dict[str, int] = {} | |
| for logger_name in ("urllib3", "httpx", "httpcore"): | |
| logger = logging.getLogger(logger_name) | |
| previous_logger_levels[logger_name] = logger.level | |
| logger.setLevel(logging.WARNING) | |
| return handler, previous_level, previous_logger_levels | |
| def _remove_log_handler( | |
| handler: logging.Handler, | |
| previous_root_level: int, | |
| previous_logger_levels: dict[str, int], | |
| ) -> None: | |
| root_logger = logging.getLogger() | |
| root_logger.removeHandler(handler) | |
| root_logger.setLevel(previous_root_level) | |
| for logger_name, level in previous_logger_levels.items(): | |
| logging.getLogger(logger_name).setLevel(level) | |
| def _duration_label(path: Path) -> str: | |
| try: | |
| result = subprocess.run( | |
| [ | |
| "ffprobe", | |
| "-v", | |
| "error", | |
| "-show_entries", | |
| "format=duration", | |
| "-of", | |
| "default=noprint_wrappers=1:nokey=1", | |
| str(path), | |
| ], | |
| check=True, | |
| capture_output=True, | |
| text=True, | |
| timeout=15, | |
| ) | |
| total = max(0, int(round(float(result.stdout.strip())))) | |
| except Exception: | |
| total = 0 | |
| return f"{total // 60}:{total % 60:02d}" if total else "0:00" | |
| def _poster_path_for_video(path: Path) -> Path: | |
| return path.with_name(f"{path.stem}.poster.jpg") | |
| def _ensure_poster(path: Path) -> Path | None: | |
| poster_path = _poster_path_for_video(path) | |
| if poster_path.is_file() and poster_path.stat().st_size > 0: | |
| return poster_path | |
| try: | |
| subprocess.run( | |
| [ | |
| "ffmpeg", | |
| "-y", | |
| "-loglevel", | |
| "error", | |
| "-ss", | |
| "0.45", | |
| "-i", | |
| str(path), | |
| "-frames:v", | |
| "1", | |
| "-q:v", | |
| "3", | |
| str(poster_path), | |
| ], | |
| check=True, | |
| capture_output=True, | |
| timeout=20, | |
| ) | |
| except Exception: | |
| return None | |
| return poster_path if poster_path.is_file() and poster_path.stat().st_size > 0 else None | |
| def _clip_file(job: Job, path: Path, duration: str | None = None) -> ClipFile: | |
| poster = _ensure_poster(path) | |
| return ClipFile( | |
| name=path.name, | |
| url=f"/api/jobs/{job.id}/files/{path.name}", | |
| duration=duration or _duration_label(path), | |
| poster_url=f"/api/jobs/{job.id}/files/{poster.name}" if poster else None, | |
| ) | |
| def _publish_files(job: Job) -> None: | |
| for path in sorted(job.output_dir.glob("short_*.mp4")): | |
| if not path.is_file(): | |
| continue | |
| duration = _duration_label(path) | |
| poster = _ensure_poster(path) | |
| poster_url = f"/api/jobs/{job.id}/files/{poster.name}" if poster else None | |
| existing = job.clips.get(path.name) | |
| if existing is None: | |
| job.clips[path.name] = _clip_file(job, path, duration=duration) | |
| else: | |
| if existing.duration == "0:00" and duration != "0:00": | |
| existing.duration = duration | |
| if existing.poster_url is None and poster_url: | |
| existing.poster_url = poster_url | |
| def _validate_credentials() -> None: | |
| if not any((os.environ.get(name) or "").strip() for name in LLM_KEY_NAMES): | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Missing LLM secret. Set GOOGLE_API_KEY, GEMINI_API_KEY, or OPENROUTER_API_KEY in the Space secrets.", | |
| ) | |
| provider = (os.environ.get("HUMEO_TRANSCRIBE_PROVIDER") or "").strip().lower() | |
| if provider in {"", "auto"}: | |
| provider = "elevenlabs" if (os.environ.get("ELEVENLABS_API_KEY") or "").strip() else "openai" | |
| if provider == "elevenlabs" and not (os.environ.get("ELEVENLABS_API_KEY") or "").strip(): | |
| raise HTTPException(status_code=400, detail="Missing ELEVENLABS_API_KEY Space secret.") | |
| if provider in {"openai", "api"} and not (os.environ.get("OPENAI_API_KEY") or "").strip(): | |
| raise HTTPException(status_code=400, detail="Missing OPENAI_API_KEY Space secret.") | |
| def _safe_url(value: str | None) -> str | None: | |
| value = (value or "").strip() | |
| if not value: | |
| return None | |
| if not re.match(r"^https?://", value, flags=re.I): | |
| raise HTTPException(status_code=400, detail="Paste a valid http(s) video URL.") | |
| return value | |
| def _snapshot(job: Job) -> dict[str, object]: | |
| return { | |
| "id": job.id, | |
| "status": job.status, | |
| "nav_status": job.nav_status, | |
| "done": job.done, | |
| "error": job.error, | |
| "created_at": job.created_at, | |
| "age_sec": max(0, int(time.time() - job.created_at)), | |
| "logs": "\n".join(job.logs[-MAX_LOG_LINES:]), | |
| "steps": job.steps, | |
| "clips": [clip.__dict__ for clip in job.clips.values()], | |
| } | |
| def _run_job(job_id: str) -> None: | |
| with JOBS_LOCK: | |
| job = JOBS[job_id] | |
| message_queue: queue.Queue[str] = queue.Queue() | |
| handler, previous_root_level, previous_logger_levels = _install_log_handler(message_queue) | |
| def drain_queue() -> None: | |
| with JOBS_LOCK: | |
| local_job = JOBS[job_id] | |
| while True: | |
| try: | |
| line = message_queue.get_nowait() | |
| except queue.Empty: | |
| break | |
| _append_log(local_job, line) | |
| _update_stage_from_log(local_job, line) | |
| _publish_files(local_job) | |
| try: | |
| with JOBS_LOCK: | |
| _append_log(job, f"Prepared source: {job.source}") | |
| _append_log(job, f"Run id: {job.id}") | |
| _set_step(job, 1, 8) | |
| config = PipelineConfig( | |
| source=job.source, | |
| youtube_url=job.source, | |
| output_dir=job.output_dir, | |
| work_dir=job.work_dir, | |
| use_video_cache=False, | |
| clean_run=True, | |
| interactive=False, | |
| prune_level="balanced", | |
| overwrite_outputs=True, | |
| steering_notes=[job.steering_note] if job.steering_note else [], | |
| ) | |
| worker_error: str | None = None | |
| outputs: list[Path] = [] | |
| def pipeline_worker() -> None: | |
| nonlocal outputs, worker_error | |
| try: | |
| outputs = run_pipeline(config) | |
| except Exception as exc: | |
| worker_error = str(exc) | |
| for line in traceback.format_exc().splitlines(): | |
| if line.strip(): | |
| message_queue.put_nowait(line) | |
| thread = threading.Thread(target=pipeline_worker, daemon=True) | |
| thread.start() | |
| while thread.is_alive(): | |
| drain_queue() | |
| time.sleep(0.35) | |
| drain_queue() | |
| with JOBS_LOCK: | |
| local_job = JOBS[job_id] | |
| for output in outputs: | |
| if Path(output).exists(): | |
| local_job.clips[Path(output).name] = _clip_file(local_job, Path(output)) | |
| if worker_error: | |
| local_job.error = worker_error | |
| local_job.status = f"Failed: {worker_error}" | |
| local_job.nav_status = "Failed" | |
| else: | |
| local_job.status = "Complete" if local_job.clips else "Complete - no clips generated" | |
| local_job.nav_status = "Done" | |
| for step in local_job.steps: | |
| step["pct"] = 100 | |
| step["state"] = "done" | |
| local_job.done = True | |
| finally: | |
| _remove_log_handler(handler, previous_root_level, previous_logger_levels) | |
| async def _stage_upload(uploaded_file: UploadFile, run_root: Path) -> Path: | |
| suffix = Path(uploaded_file.filename or "input.mp4").suffix or ".mp4" | |
| staged_path = run_root / f"input{suffix}" | |
| with staged_path.open("wb") as handle: | |
| while chunk := await uploaded_file.read(1024 * 1024): | |
| handle.write(chunk) | |
| return staged_path | |
| app = FastAPI(title=APP_TITLE) | |
| def index() -> str: | |
| return INDEX_HTML | |
| async def create_job( | |
| video_url: Annotated[str | None, Form()] = None, | |
| regen_prompt: Annotated[str | None, Form()] = None, | |
| source_job_id: Annotated[str | None, Form()] = None, | |
| file: Annotated[UploadFile | None, File()] = None, | |
| ) -> JSONResponse: | |
| _validate_credentials() | |
| job_id = uuid.uuid4().hex[:12] | |
| run_root = Path(tempfile.mkdtemp(prefix=f"clipforge-{job_id}-")) | |
| work_dir = run_root / "work" | |
| output_dir = run_root / "output" | |
| work_dir.mkdir(parents=True, exist_ok=True) | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| source_path: Path | None = None | |
| source = _safe_url(video_url) | |
| source_job_id = (source_job_id or "").strip() | |
| if source_job_id: | |
| with JOBS_LOCK: | |
| previous = JOBS.get(source_job_id) | |
| if previous is None: | |
| raise HTTPException(status_code=404, detail="Previous job not found for regeneration.") | |
| if previous.source_path and previous.source_path.exists(): | |
| source_path = run_root / previous.source_path.name | |
| shutil.copy2(previous.source_path, source_path) | |
| source = str(source_path) | |
| else: | |
| source = previous.source | |
| elif file is not None: | |
| source_path = await _stage_upload(file, run_root) | |
| source = str(source_path) | |
| if not source: | |
| raise HTTPException(status_code=400, detail="Upload a video file or paste a video URL first.") | |
| job = Job( | |
| id=job_id, | |
| run_root=run_root, | |
| output_dir=output_dir, | |
| work_dir=work_dir, | |
| source=source, | |
| source_path=source_path, | |
| steering_note=(regen_prompt or "").strip() or None, | |
| ) | |
| with JOBS_LOCK: | |
| JOBS[job_id] = job | |
| threading.Thread(target=_run_job, args=(job_id,), daemon=True).start() | |
| return JSONResponse(_snapshot(job)) | |
| def list_jobs() -> JSONResponse: | |
| with JOBS_LOCK: | |
| jobs = sorted(JOBS.values(), key=lambda item: item.created_at, reverse=True)[:10] | |
| for job in jobs: | |
| _publish_files(job) | |
| return JSONResponse( | |
| { | |
| "jobs": [ | |
| { | |
| "id": job.id, | |
| "status": job.status, | |
| "nav_status": job.nav_status, | |
| "done": job.done, | |
| "error": job.error, | |
| "created_at": job.created_at, | |
| "age_sec": max(0, int(time.time() - job.created_at)), | |
| "clip_count": len(job.clips), | |
| } | |
| for job in jobs | |
| ] | |
| } | |
| ) | |
| async def probe_upload(file: Annotated[UploadFile | None, File()] = None) -> JSONResponse: | |
| if file is None: | |
| raise HTTPException(status_code=400, detail="Choose a video file first.") | |
| total = 0 | |
| while chunk := await file.read(1024 * 1024): | |
| total += len(chunk) | |
| return JSONResponse( | |
| { | |
| "ok": True, | |
| "filename": file.filename or "upload.mp4", | |
| "content_type": file.content_type or "", | |
| "bytes": total, | |
| } | |
| ) | |
| def get_job(job_id: str) -> JSONResponse: | |
| with JOBS_LOCK: | |
| job = JOBS.get(job_id) | |
| if job is None: | |
| raise HTTPException(status_code=404, detail="Job not found.") | |
| _publish_files(job) | |
| return JSONResponse(_snapshot(job)) | |
| def get_job_file(job_id: str, filename: str) -> FileResponse: | |
| with JOBS_LOCK: | |
| job = JOBS.get(job_id) | |
| if job is None: | |
| raise HTTPException(status_code=404, detail="Job not found.") | |
| path = (job.output_dir / Path(filename).name).resolve(strict=False) | |
| if job.output_dir.resolve(strict=False) not in path.parents or not path.is_file(): | |
| raise HTTPException(status_code=404, detail="File not found.") | |
| media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "video/mp4" | |
| return FileResponse(path, media_type=media_type, filename=path.name) | |
| def health() -> dict[str, str]: | |
| return {"ok": "true"} | |
| INDEX_HTML = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Humeo - long to shorts</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --cream: #F7F2E9; --champagne: #EDE3CC; --champagne-deep: #D9C9A6; | |
| --gold: #B8924A; --gold-light: #D4AA6A; --ink: #2A1F0E; | |
| --ink-soft: #5C4A2E; --ink-muted: #9A8560; --white: #FDFAF4; | |
| --surface: #F0E9D8; --border: #DDD0B3; --success: #6B8C5A; | |
| --radius: 12px; --radius-lg: 20px; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { font-family: 'DM Sans', sans-serif; background: var(--cream); color: var(--ink); min-height: 100vh; overflow-x: hidden; } | |
| nav { display: flex; align-items: center; justify-content: space-between; padding: 20px 32px; border-bottom: 1px solid var(--border); background: var(--white); position: sticky; top: 0; z-index: 100; } | |
| .logo { font-family: 'Cormorant Garamond', serif; font-size: 1.6rem; font-weight: 600; color: var(--ink); letter-spacing: 0.02em; } | |
| .logo span { color: var(--gold); } | |
| .screen { display: none; animation: fadeIn 0.5s ease; } | |
| .screen.active { display: block; } | |
| @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } | |
| #screen-input.active { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: calc(100vh - 65px); padding: 40px 20px; text-align: center; } | |
| .eyebrow { font-size: 0.75rem; letter-spacing: 0.18em; text-transform: uppercase; color: var(--gold); font-weight: 500; margin-bottom: 16px; } | |
| .hero-title { font-family: 'Cormorant Garamond', serif; font-size: clamp(2rem, 5vw, 3.6rem); font-weight: 500; line-height: 1.15; color: var(--ink); max-width: 620px; margin-bottom: 12px; } | |
| .hero-title em { font-style: italic; color: var(--gold); } | |
| .typing-line { display: inline; border-right: 0.045em solid var(--gold); padding-right: 0.04em; } | |
| @media (prefers-reduced-motion: reduce) { .typing-line { border-right: 0; } } | |
| .hero-sub { font-size: 0.95rem; color: var(--ink-muted); margin-bottom: 48px; font-weight: 300; } | |
| .input-card { background: var(--white); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 36px; width: 100%; max-width: 520px; box-shadow: 0 8px 32px rgba(42,31,14,0.07); } | |
| .mode-tabs { display: flex; background: var(--surface); border-radius: 10px; padding: 4px; margin-bottom: 28px; gap: 4px; } | |
| .mode-tab { flex: 1; padding: 10px 0; border: none; background: transparent; border-radius: 8px; font-family: 'DM Sans', sans-serif; font-size: 0.85rem; font-weight: 500; color: var(--ink-muted); cursor: pointer; transition: all 0.2s; } | |
| .mode-tab.active { background: var(--white); color: var(--ink); box-shadow: 0 2px 8px rgba(42,31,14,0.1); } | |
| .input-section { display: none; } .input-section.active { display: block; } | |
| .input-label { font-size: 0.78rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-muted); margin-bottom: 8px; display: block; font-weight: 500; text-align:left; } | |
| .yt-input { width: 100%; padding: 14px 16px; border: 1.5px solid var(--border); border-radius: var(--radius); font-family: 'DM Sans', sans-serif; font-size: 0.9rem; background: var(--cream); color: var(--ink); outline: none; transition: border-color 0.2s; } | |
| .yt-input:focus { border-color: var(--gold); } .yt-input::placeholder { color: var(--ink-muted); } | |
| .native-file-input { width: 100%; padding: 12px; margin-bottom: 12px; border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--cream); color: var(--ink-soft); font-family: 'DM Sans', sans-serif; font-size: 0.86rem; } | |
| .native-file-input::file-selector-button { margin-right: 12px; padding: 9px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--white); color: var(--ink); font-family: 'DM Sans', sans-serif; cursor: pointer; } | |
| .native-file-input::file-selector-button:hover { background: var(--champagne); } | |
| .upload-zone { border: 2px dashed var(--champagne-deep); border-radius: var(--radius); padding: 28px 20px; text-align: center; cursor: pointer; transition: all 0.2s; background: var(--cream); } | |
| .upload-zone:hover, .upload-zone.dragover { border-color: var(--gold); background: var(--champagne); } | |
| .upload-icon { width: 44px; height: 44px; background: var(--champagne); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 12px; font-size: 1.2rem; } | |
| .upload-text { font-size: 0.9rem; color: var(--ink-soft); font-weight: 400; } | |
| .upload-sub { font-size: 0.78rem; color: var(--ink-muted); margin-top: 4px; } | |
| .convert-btn { width: 100%; margin-top: 28px; padding: 16px; background: var(--ink); color: var(--cream); border: none; border-radius: var(--radius); font-family: 'DM Sans', sans-serif; font-size: 0.95rem; font-weight: 500; cursor: pointer; letter-spacing: 0.03em; transition: all 0.2s; position: relative; overflow: hidden; } | |
| .convert-btn:hover { background: var(--ink-soft); transform: translateY(-1px); box-shadow: 0 6px 20px rgba(42,31,14,0.2); } .convert-btn:active { transform: translateY(0); } | |
| .convert-btn:disabled { opacity: .65; cursor: progress; transform:none; } | |
| #screen-processing { max-width: 780px; margin: 0 auto; padding: 48px 20px 80px; } | |
| .processing-header { text-align: center; margin-bottom: 40px; } | |
| .processing-title { font-family: 'Cormorant Garamond', serif; font-size: 2rem; font-weight: 500; color: var(--ink); margin-bottom: 6px; } | |
| .processing-sub { font-size: 0.88rem; color: var(--ink-muted); font-weight: 300; } | |
| .pipeline { background: var(--white); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 28px; box-shadow: 0 4px 20px rgba(42,31,14,0.06); margin-bottom: 32px; } | |
| .pipeline-step { display: flex; align-items: flex-start; gap: 16px; padding: 16px 0; border-bottom: 1px solid var(--champagne); opacity: 0.4; transition: opacity 0.4s; } | |
| .pipeline-step:last-child { border-bottom: none; } .pipeline-step.active, .pipeline-step.done { opacity: 1; } | |
| .step-icon { width: 36px; height: 36px; flex-shrink: 0; background: var(--surface); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1rem; transition: all 0.4s; border: 1.5px solid var(--border); } | |
| .pipeline-step.active .step-icon { background: var(--champagne); border-color: var(--gold); } | |
| .pipeline-step.done .step-icon { background: var(--gold); border-color: var(--gold); color: white; font-size: 0.85rem; } | |
| .step-content { flex: 1; padding-top: 4px; } | |
| .step-name { font-size: 0.9rem; font-weight: 500; color: var(--ink); margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; } | |
| .step-pct { font-size: 0.8rem; color: var(--gold); font-weight: 500; } | |
| .progress-track { height: 6px; background: var(--surface); border-radius: 99px; overflow: hidden; } | |
| .progress-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--gold-light), var(--gold)); width: 0%; transition: width 0.25s ease; } | |
| .pipeline-step.done .progress-fill { width: 100%; background: var(--gold); } | |
| .tips-section { margin-bottom: 40px; } | |
| .tips-label { font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-muted); margin-bottom: 12px; font-weight: 500; } | |
| .tip-card { background: var(--champagne); border-radius: var(--radius); padding: 14px 18px; font-size: 0.85rem; color: var(--ink-soft); display: flex; align-items: flex-start; gap: 10px; margin-bottom: 8px; line-height: 1.5; } | |
| .tip-dot { color: var(--gold); margin-top: 2px; flex-shrink: 0; } | |
| .clips-section { margin-top: 8px; } | |
| .clips-title { font-family: 'Cormorant Garamond', serif; font-size: 1.4rem; font-weight: 500; color: var(--ink); margin-bottom: 6px; } | |
| .clips-sub { font-size: 0.82rem; color: var(--ink-muted); margin-bottom: 20px; font-weight: 300; } | |
| .clips-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; } | |
| .clip-card { border-radius: var(--radius); overflow: hidden; cursor: pointer; background: var(--white); border: 1px solid var(--border); box-shadow: 0 2px 10px rgba(42,31,14,0.06); transition: all 0.2s; animation: clipAppear 0.5s ease both; } | |
| .clip-card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px rgba(42,31,14,0.13); } | |
| @keyframes clipAppear { from { opacity: 0; transform: scale(0.9) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } | |
| .clip-thumb { aspect-ratio: 9/16; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; background:#000; } | |
| .clip-thumb video { width:calc(100% + 4px); height:calc(100% + 4px); max-width:none; flex:0 0 auto; object-fit:cover; display:block; background:#000; } | |
| .clip-thumb::after { content:""; position:absolute; inset:0; background:linear-gradient(180deg, rgba(0,0,0,0.02), rgba(0,0,0,0.18)); pointer-events:none; } | |
| .clip-play { width: 44px; height: 44px; background: rgba(255,255,255,0.88); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; z-index: 2; box-shadow: 0 2px 12px rgba(0,0,0,0.2); transition: transform 0.2s, opacity 0.2s; pointer-events:none; } | |
| .clip-card:hover .clip-play { transform: scale(1.1); } | |
| .clip-card.previewing .clip-play { opacity: 0; transform: scale(0.92); } | |
| .clip-skim { position:absolute; left:10px; right:10px; bottom:10px; height:3px; border-radius:99px; background:rgba(255,255,255,0.35); z-index:3; overflow:hidden; opacity:0; transition:opacity 0.2s; pointer-events:none; } | |
| .clip-skim-fill { height:100%; width:0%; background:var(--champagne); border-radius:inherit; } | |
| .clip-card:hover .clip-skim { opacity:1; } | |
| .clip-meta { padding: 10px 12px; } .clip-num { font-size: 0.72rem; color: var(--ink-muted); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 500; } | |
| .clip-dur { font-size: 0.82rem; color: var(--ink); font-weight: 400; margin-top: 2px; } | |
| .clip-download { margin-top: 8px; display:inline-block; font-size:.74rem; color:var(--gold); text-decoration:none; } | |
| .regen-section { margin-top: 56px; background: var(--white); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 32px; display: none; animation: fadeIn 0.5s ease; box-shadow: 0 4px 20px rgba(42,31,14,0.06); } | |
| .regen-title { font-family: 'Cormorant Garamond', serif; font-size: 1.5rem; font-weight: 500; margin-bottom: 6px; } | |
| .regen-sub { font-size: 0.85rem; color: var(--ink-muted); margin-bottom: 20px; font-weight: 300; } | |
| .regen-textarea { width: 100%; min-height: 100px; padding: 14px 16px; border: 1.5px solid var(--border); border-radius: var(--radius); font-family: 'DM Sans', sans-serif; font-size: 0.9rem; background: var(--cream); color: var(--ink); outline: none; resize: vertical; transition: border-color 0.2s; line-height: 1.6; margin-bottom: 14px; } | |
| .regen-textarea:focus { border-color: var(--gold); } .regen-textarea::placeholder { color: var(--ink-muted); } | |
| .regen-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } | |
| .chip { padding: 7px 14px; background: var(--champagne); border: 1px solid var(--border); border-radius: 99px; font-size: 0.78rem; color: var(--ink-soft); cursor: pointer; transition: all 0.15s; font-weight: 400; white-space: nowrap; } | |
| .chip:hover { background: var(--champagne-deep); color: var(--ink); border-color: var(--gold); } | |
| .regen-btn { margin-left: auto; padding: 12px 24px; background: var(--ink); color: var(--cream); border: none; border-radius: var(--radius); font-family: 'DM Sans', sans-serif; font-size: 0.88rem; font-weight: 500; cursor: pointer; transition: all 0.2s; white-space: nowrap; } | |
| .regen-btn:hover { background: var(--ink-soft); } | |
| .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(42,31,14,0.65); backdrop-filter: blur(6px); z-index: 500; align-items: center; justify-content: center; padding: 20px; animation: fadeIn 0.25s ease; } | |
| .modal-overlay.open { display: flex; } | |
| .modal-box { background: var(--white); border-radius: var(--radius-lg); width: min(390px, calc((100vh - 130px) * 9 / 16), calc(100vw - 40px)); max-width: none; overflow: hidden; box-shadow: 0 24px 64px rgba(42,31,14,0.25); animation: slideUp 0.3s ease; } | |
| @keyframes slideUp { from { opacity: 0; transform: translateY(20px) scale(0.97); } to { opacity: 1; transform: translateY(0) scale(1); } } | |
| .modal-video { width: 100%; aspect-ratio: 9/16; display: flex; align-items: center; justify-content: center; position: relative; background:#000; } | |
| .modal-video video { width:100%; height:100%; object-fit:cover; background:#000; display:block; } | |
| video:fullscreen { width:100vw !important; height:100vh !important; object-fit:contain !important; background:#000 !important; } | |
| video:-webkit-full-screen { width:100vw !important; height:100vh !important; object-fit:contain !important; background:#000 !important; } | |
| .modal-footer { padding: 16px 20px; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap:12px; } | |
| .modal-clip-label { font-family: 'Cormorant Garamond', serif; font-size: 1.1rem; font-weight: 500; } | |
| .modal-actions { display:flex; align-items:center; gap:8px; } | |
| .modal-close, .modal-download { padding: 8px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; font-family: 'DM Sans', sans-serif; font-size: 0.82rem; cursor: pointer; transition: all 0.15s; color:var(--ink); text-decoration:none; } | |
| .modal-close:hover, .modal-download:hover { background: var(--champagne); } | |
| .job-status-card { margin: -18px 0 24px; background: var(--white); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; box-shadow: 0 2px 10px rgba(42,31,14,0.04); } | |
| .job-status-main { display: flex; flex-direction: column; gap: 3px; text-align: left; min-width: 0; } | |
| .job-status-main strong { font-size: 0.78rem; color: var(--ink); font-weight: 500; overflow-wrap: anywhere; } | |
| .job-status-main span { font-size: 0.75rem; color: var(--ink-muted); } | |
| .job-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } | |
| .job-action-btn { padding: 8px 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; color: var(--ink); font-family: 'DM Sans', sans-serif; font-size: 0.78rem; cursor: pointer; } | |
| .job-action-btn:hover { background: var(--champagne); } | |
| .nav-right { display:flex; flex-direction:column; align-items:flex-end; gap:6px; } | |
| .nav-status { font-size:0.8rem; color:var(--ink-muted); font-weight:300; display:none; } | |
| .nav-new-session { display:none; padding:7px 12px; background:var(--surface); border:1px solid var(--border); border-radius:8px; color:var(--ink); font-family:'DM Sans', sans-serif; font-size:0.76rem; cursor:pointer; transition:all 0.15s; } | |
| .nav-new-session:hover { background:var(--champagne); border-color:var(--gold); } | |
| .nav-new-session.show { display:inline-flex; } | |
| @media (max-width: 600px) { nav { padding: 16px 20px; } .input-card { padding: 24px 20px; } #screen-processing { padding: 32px 16px 60px; } .pipeline { padding: 20px 16px; } .job-status-card { flex-direction: column; align-items: stretch; } .job-actions { justify-content: flex-start; } .clips-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; } .regen-section { padding: 22px 18px; } .regen-btn { width: 100%; margin-left: 0; } .regen-row { flex-direction: column; align-items: flex-start; } .nav-new-session { padding:7px 10px; } } | |
| .thumb-1 { background: linear-gradient(135deg, #D4A96A 0%, #8B5E3C 100%); } .thumb-2 { background: linear-gradient(135deg, #7A9E8A 0%, #3D6650 100%); } | |
| .thumb-3 { background: linear-gradient(135deg, #9E8A7A 0%, #5C3E2E 100%); } .thumb-4 { background: linear-gradient(135deg, #8A7A9E 0%, #4A3866 100%); } | |
| .thumb-5 { background: linear-gradient(135deg, #9E9A7A 0%, #5C5820 100%); } .thumb-6 { background: linear-gradient(135deg, #C4856A 0%, #7A3020 100%); } | |
| .thumb-7 { background: linear-gradient(135deg, #7AABBE 0%, #2A5A6E 100%); } .thumb-8 { background: linear-gradient(135deg, #9EAA7A 0%, #4A5E20 100%); } | |
| .thumb-9 { background: linear-gradient(135deg, #AA7A9E 0%, #5E2060 100%); } .thumb-0 { background: linear-gradient(135deg, #D4C36A 0%, #8B7820 100%); } | |
| .spin { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--gold); border-radius: 50%; animation: spin 0.8s linear infinite; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| </style> | |
| </head> | |
| <body> | |
| <nav> | |
| <div class="logo">Humeo <span>- long to shorts</span></div> | |
| <div class="nav-right"> | |
| <div class="nav-status" id="nav-status">Processing...</div> | |
| <button class="nav-new-session" id="new-session-btn" type="button" onclick="startNewSession()">New session</button> | |
| </div> | |
| </nav> | |
| <div class="screen active" id="screen-input"> | |
| <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:calc(100vh - 65px);padding:40px 20px;text-align:center;"> | |
| <h1 class="hero-title" aria-label="Convert your long video to short clips for social media"><span class="typing-line" id="hero-typed">Convert your long video to <em>short clips</em> for social media</span></h1> | |
| <p class="hero-sub">Upload a file - we handle the rest</p> | |
| <div class="input-card"> | |
| <div class="input-section" id="mode-yt" style="display:none"> | |
| <input class="yt-input" type="hidden" id="yt-url"> | |
| </div> | |
| <div class="input-section active" id="mode-upload"> | |
| <label class="input-label">Video file</label> | |
| <input class="native-file-input" type="file" id="file-input" accept="video/mp4,video/quicktime,video/*"> | |
| <div class="upload-zone" id="upload-zone" onclick="openUpload()"> | |
| <div class="upload-icon">File</div> | |
| <div class="upload-text">Click to browse or drag & drop</div> | |
| <div class="upload-sub">MP4, MOV, AVI - up to your Space limit</div> | |
| </div> | |
| </div> | |
| <button class="convert-btn" id="convert-btn" onclick="startProcessing()">Convert to Clips -></button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="screen" id="screen-processing"> | |
| <div class="processing-header"> | |
| <div class="eyebrow">Working on it</div> | |
| <h2 class="processing-title">Your clips are being crafted</h2> | |
| <p class="processing-sub" id="processing-sub">Sit back - long videos can take a little while</p> | |
| </div> | |
| <div class="job-status-card"> | |
| <div class="job-status-main"> | |
| <strong id="job-id-label">Job pending</strong> | |
| <span id="poll-status-label">Waiting for updates</span> | |
| </div> | |
| <div class="job-actions"> | |
| <button class="job-action-btn" onclick="manualReconnect()">Reconnect</button> | |
| </div> | |
| </div> | |
| <div class="pipeline" id="pipeline"> | |
| <div class="pipeline-step" id="step-0"><div class="step-icon">Up</div><div class="step-content"><div class="step-name">Uploading video <span class="step-pct" id="pct-0">0%</span></div><div class="progress-track"><div class="progress-fill" id="fill-0"></div></div></div></div> | |
| <div class="pipeline-step" id="step-1"><div class="step-icon">Text</div><div class="step-content"><div class="step-name">Generating transcript <span class="step-pct" id="pct-1"></span></div><div class="progress-track"><div class="progress-fill" id="fill-1"></div></div></div></div> | |
| <div class="pipeline-step" id="step-2"><div class="step-icon">Cut</div><div class="step-content"><div class="step-name">Choosing short clips <span class="step-pct" id="pct-2"></span></div><div class="progress-track"><div class="progress-fill" id="fill-2"></div></div></div></div> | |
| <div class="pipeline-step" id="step-3"><div class="step-icon">Film</div><div class="step-content"><div class="step-name">Producing clips <span class="step-pct" id="pct-3"></span></div><div class="progress-track"><div class="progress-fill" id="fill-3"></div></div></div></div> | |
| <div class="pipeline-step" id="step-4"><div class="step-icon">Edit</div><div class="step-content"><div class="step-name">Adding subtitles & light edits <span class="step-pct" id="pct-4"></span></div><div class="progress-track"><div class="progress-fill" id="fill-4"></div></div></div></div> | |
| </div> | |
| <div class="tips-section" id="tips-section"> | |
| <div class="tips-label">What happens while you're waiting</div> | |
| <div class="tip-card"><span class="tip-dot">◆</span> Clips are automatically trimmed around the strongest hook.</div> | |
| <div class="tip-card"><span class="tip-dot">◆</span> The system can pick centered speaker or split presentation layout per clip.</div> | |
| <div class="tip-card"><span class="tip-dot">◆</span> Word-by-word subtitles are added by default.</div> | |
| <div class="tip-card"><span class="tip-dot">◆</span> You can regenerate with different instructions after the first batch.</div> | |
| </div> | |
| <div class="clips-section" id="clips-section" style="display:none"> | |
| <div class="clips-title">Your clips</div> | |
| <p class="clips-sub" id="clips-sub-text">Tap any clip to preview</p> | |
| <div class="clips-grid" id="clips-grid"></div> | |
| </div> | |
| <div class="regen-section" id="regen-section"> | |
| <div class="regen-title">Produce a different set</div> | |
| <p class="regen-sub">Describe what you're looking for and we'll re-cut your video</p> | |
| <textarea class="regen-textarea" placeholder="e.g. Focus on the funniest moments, keep clips under 30 seconds, add a text hook at the start..." id="regen-prompt"></textarea> | |
| <div class="regen-row"> | |
| <span class="chip" onclick="setChip('Highlight key insights')">Key insights</span> | |
| <span class="chip" onclick="setChip('Funny & entertaining moments')">Funny moments</span> | |
| <span class="chip" onclick="setChip('Emotional or inspiring clips')">Emotional</span> | |
| <span class="chip" onclick="setChip('Fast-paced, high energy edits')">High energy</span> | |
| <button class="regen-btn" onclick="triggerRegen()">Regenerate Clips -></button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="modal-overlay" id="modal" onclick="closeModal(event)"> | |
| <div class="modal-box"> | |
| <div class="modal-video" id="modal-video"><div class="clip-play" style="width:56px;height:56px;font-size:1.4rem;background:rgba(255,255,255,0.9)">▶</div></div> | |
| <div class="modal-footer"> | |
| <div class="modal-clip-label" id="modal-label">Clip 1</div> | |
| <div class="modal-actions"><a class="modal-download" id="modal-download" href="#" download>Download</a><button class="modal-close" onclick="document.getElementById('modal').classList.remove('open')">Close</button></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let currentMode = 'upload'; | |
| let selectedFile = null; | |
| let autoStartAfterFilePick = false; | |
| let currentJobId = null; | |
| let renderedClips = []; | |
| const iconLabels = ['Up','Text','Cut','Film','Edit']; | |
| const JOB_STORAGE_KEY = 'clipforge_current_job_id'; | |
| let pollToken = 0; | |
| let pollFailures = 0; | |
| let lastJobSnapshot = null; | |
| function startHeroTyping() { | |
| const el = document.getElementById('hero-typed'); | |
| if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; | |
| const text = 'Convert your long video to short clips for social media'; | |
| const highlight = 'short clips'; | |
| const highlightStart = text.indexOf(highlight); | |
| const highlightEnd = highlightStart + highlight.length; | |
| const typeMs = 3500; | |
| const eraseMs = 3500; | |
| const emptyHoldMs = 800; | |
| const fullHoldMs = 4000; | |
| let index = 0; | |
| let deleting = false; | |
| function render(count) { | |
| el.replaceChildren(); | |
| if (count <= 0) { | |
| el.textContent = '\u00a0'; | |
| return; | |
| } | |
| const beforeEnd = Math.min(count, highlightStart); | |
| if (beforeEnd > 0) { | |
| el.append(document.createTextNode(text.slice(0, beforeEnd))); | |
| } | |
| if (count > highlightStart) { | |
| const em = document.createElement('em'); | |
| em.textContent = text.slice(highlightStart, Math.min(count, highlightEnd)); | |
| el.append(em); | |
| } | |
| if (count > highlightEnd) { | |
| el.append(document.createTextNode(text.slice(highlightEnd, count))); | |
| } | |
| } | |
| function step() { | |
| const delay = (deleting ? eraseMs : typeMs) / text.length; | |
| if (deleting) { | |
| index = Math.max(0, index - 1); | |
| render(index); | |
| if (index === 0) { | |
| deleting = false; | |
| setTimeout(step, emptyHoldMs); | |
| return; | |
| } | |
| } else { | |
| index = Math.min(text.length, index + 1); | |
| render(index); | |
| if (index === text.length) { | |
| deleting = true; | |
| setTimeout(step, fullHoldMs); | |
| return; | |
| } | |
| } | |
| setTimeout(step, delay); | |
| } | |
| render(index); | |
| setTimeout(step, emptyHoldMs); | |
| } | |
| startHeroTyping(); | |
| function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } | |
| function showProcessingScreen() { | |
| document.getElementById('screen-input').classList.remove('active'); | |
| document.getElementById('screen-processing').classList.add('active'); | |
| document.getElementById('nav-status').style.display = 'block'; | |
| } | |
| function resetPipelineUi() { | |
| document.querySelectorAll('.pipeline-step').forEach((s, i) => { | |
| s.classList.remove('active', 'done'); | |
| s.querySelector('.step-icon').innerHTML = iconLabels[i]; | |
| document.getElementById(`fill-${i}`).style.width = '0%'; | |
| document.getElementById(`pct-${i}`).textContent = i === 0 ? '0%' : ''; | |
| }); | |
| } | |
| function resetUploadUi() { | |
| document.getElementById('upload-zone').innerHTML = '<div class="upload-icon">File</div><div class="upload-text">Click to browse or drag & drop</div><div class="upload-sub">MP4, MOV, AVI - up to your Space limit</div>'; | |
| } | |
| function startNewSession() { | |
| pollToken += 1; | |
| currentJobId = null; | |
| renderedClips = []; | |
| selectedFile = null; | |
| autoStartAfterFilePick = false; | |
| pollFailures = 0; | |
| lastJobSnapshot = null; | |
| try { localStorage.removeItem(JOB_STORAGE_KEY); } catch (_) {} | |
| document.getElementById('screen-processing').classList.remove('active'); | |
| document.getElementById('screen-input').classList.add('active'); | |
| document.getElementById('nav-status').textContent = 'Processing...'; | |
| document.getElementById('nav-status').style.display = 'none'; | |
| document.getElementById('new-session-btn').classList.remove('show'); | |
| document.getElementById('job-id-label').textContent = 'Job pending'; | |
| setPollStatus('Waiting for updates'); | |
| document.getElementById('processing-sub').textContent = 'Sit back - long videos can take a little while'; | |
| document.getElementById('clips-grid').innerHTML = ''; | |
| document.getElementById('clips-section').style.display = 'none'; | |
| document.getElementById('clips-sub-text').textContent = 'Tap any clip to preview'; | |
| document.getElementById('regen-section').style.display = 'none'; | |
| document.getElementById('regen-prompt').value = ''; | |
| document.getElementById('file-input').value = ''; | |
| document.getElementById('yt-url').value = ''; | |
| document.getElementById('convert-btn').disabled = false; | |
| document.getElementById('convert-btn').textContent = 'Convert to Clips ->'; | |
| resetUploadUi(); | |
| resetPipelineUi(); | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| } | |
| function setPollStatus(text) { | |
| document.getElementById('poll-status-label').textContent = text; | |
| } | |
| function rememberJob(id) { | |
| currentJobId = id; | |
| try { localStorage.setItem(JOB_STORAGE_KEY, id); } catch (_) {} | |
| document.getElementById('job-id-label').textContent = `Job ${id}`; | |
| } | |
| function forgetJob(id) { | |
| if (!id || currentJobId === id) { | |
| try { localStorage.removeItem(JOB_STORAGE_KEY); } catch (_) {} | |
| } | |
| } | |
| function manualReconnect() { | |
| if (currentJobId) { | |
| pollJob(currentJobId, { immediate: true }); | |
| } | |
| } | |
| function switchMode(m) { | |
| currentMode = m; | |
| document.querySelectorAll('.mode-tab').forEach((t,i) => t.classList.toggle('active', (i===0 && m==='yt') || (i===1 && m==='upload'))); | |
| document.getElementById('mode-yt').classList.toggle('active', m==='yt'); | |
| document.getElementById('mode-upload').classList.toggle('active', m==='upload'); | |
| } | |
| function openUpload() { document.getElementById('file-input').click(); } | |
| function setSelectedFile(file) { | |
| if (!file) return; | |
| selectedFile = file; | |
| const zone = document.getElementById('upload-zone'); | |
| zone.innerHTML = `<div class="upload-icon">OK</div><div class="upload-text" style="color:var(--gold)">File selected: ${escapeHtml(file.name)}</div><div class="upload-sub">Ready to convert</div>`; | |
| } | |
| const uploadZone = document.getElementById('upload-zone'); | |
| document.getElementById('file-input').addEventListener('change', e => { | |
| if (e.target.files[0]) { | |
| setSelectedFile(e.target.files[0]); | |
| if (autoStartAfterFilePick) { | |
| autoStartAfterFilePick = false; | |
| startProcessing(); | |
| } | |
| } | |
| }); | |
| uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); }); | |
| uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover')); | |
| uploadZone.addEventListener('drop', e => { e.preventDefault(); uploadZone.classList.remove('dragover'); if (e.dataTransfer.files[0]) setSelectedFile(e.dataTransfer.files[0]); }); | |
| function escapeHtml(s) { | |
| return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| } | |
| async function createJob(extraPrompt = '') { | |
| const form = new FormData(); | |
| if (extraPrompt && currentJobId) { | |
| form.append('source_job_id', currentJobId); | |
| form.append('regen_prompt', extraPrompt); | |
| } else if (currentMode === 'upload') { | |
| const file = selectedFile || document.getElementById('file-input').files[0]; | |
| if (!file) throw new Error('Choose a video file first.'); | |
| form.append('file', file); | |
| } else { | |
| const url = document.getElementById('yt-url').value.trim(); | |
| if (!url) throw new Error('Paste a video URL first.'); | |
| form.append('video_url', url); | |
| } | |
| const res = await fetch('/api/jobs', { method: 'POST', body: form }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || 'Could not start job.'); | |
| return data; | |
| } | |
| async function startProcessing() { | |
| const btn = document.getElementById('convert-btn'); | |
| try { | |
| if (currentMode === 'upload') { | |
| selectedFile = selectedFile || document.getElementById('file-input').files[0] || null; | |
| if (!selectedFile) { | |
| autoStartAfterFilePick = true; | |
| openUpload(); | |
| return; | |
| } | |
| } | |
| btn.disabled = true; | |
| btn.textContent = 'Starting...'; | |
| document.getElementById('new-session-btn').classList.remove('show'); | |
| const job = await createJob(); | |
| rememberJob(job.id); | |
| renderedClips = []; | |
| document.getElementById('clips-grid').innerHTML = ''; | |
| showProcessingScreen(); | |
| setPollStatus('Connected - waiting for progress'); | |
| syncJob(job); | |
| pollJob(job.id); | |
| } catch (err) { | |
| alert(err.message || err); | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = 'Convert to Clips ->'; | |
| } | |
| } | |
| async function pollJob(id, options = {}) { | |
| const token = ++pollToken; | |
| rememberJob(id); | |
| showProcessingScreen(); | |
| if (options.immediate) setPollStatus('Reconnecting...'); | |
| let done = false; | |
| while (!done && currentJobId === id && token === pollToken) { | |
| if (!options.immediate) await sleep(pollFailures ? Math.min(10000, 1800 + pollFailures * 1800) : 1400); | |
| options.immediate = false; | |
| try { | |
| const res = await fetch(`/api/jobs/${id}?t=${Date.now()}`, { cache: 'no-store' }); | |
| if (!res.ok) { | |
| if (res.status === 404) { | |
| forgetJob(id); | |
| currentJobId = null; | |
| setPollStatus('Job not found - start a new upload'); | |
| break; | |
| } | |
| throw new Error(`Job refresh failed (${res.status})`); | |
| } | |
| const job = await res.json(); | |
| pollFailures = 0; | |
| lastJobSnapshot = job; | |
| syncJob(job); | |
| done = Boolean(job.done); | |
| setPollStatus(done ? 'Finished' : `Connected - updated ${new Date().toLocaleTimeString()}`); | |
| } catch (err) { | |
| pollFailures += 1; | |
| setPollStatus(`Connection hiccup - retrying (${pollFailures})`); | |
| if (lastJobSnapshot) syncJob(lastJobSnapshot); | |
| } | |
| } | |
| } | |
| function syncJob(job) { | |
| if (!job || !job.id) return; | |
| document.getElementById('job-id-label').textContent = `Job ${job.id}`; | |
| document.getElementById('nav-status').textContent = job.nav_status || 'Processing...'; | |
| document.getElementById('new-session-btn').classList.toggle('show', Boolean(job.done)); | |
| document.getElementById('processing-sub').textContent = job.error ? job.error : job.status; | |
| (job.steps || []).forEach((step, i) => { | |
| const el = document.getElementById(`step-${i}`); | |
| const fill = document.getElementById(`fill-${i}`); | |
| const pct = document.getElementById(`pct-${i}`); | |
| el.classList.toggle('active', step.state === 'active'); | |
| el.classList.toggle('done', step.state === 'done'); | |
| el.querySelector('.step-icon').innerHTML = step.state === 'done' ? '✓' : (step.state === 'active' ? '<span class="spin"></span>' : iconLabels[i]); | |
| fill.style.width = `${step.pct || 0}%`; | |
| pct.textContent = step.pct ? `${Math.floor(step.pct)}%` : ''; | |
| }); | |
| (job.clips || []).forEach((clip, idx) => { | |
| const existingIdx = renderedClips.findIndex(c => c.name === clip.name); | |
| if (existingIdx === -1) { | |
| renderedClips.push(clip); | |
| addClip(renderedClips.length - 1); | |
| } else { | |
| renderedClips[existingIdx] = Object.assign({}, renderedClips[existingIdx], clip); | |
| updateClipCard(existingIdx); | |
| } | |
| }); | |
| if (renderedClips.length) { | |
| document.getElementById('clips-section').style.display = 'block'; | |
| document.getElementById('clips-sub-text').textContent = job.done | |
| ? `All ${renderedClips.length} clip${renderedClips.length > 1 ? 's' : ''} ready - tap to preview` | |
| : `${renderedClips.length} clip${renderedClips.length > 1 ? 's' : ''} ready - more coming...`; | |
| } | |
| if (job.done) { | |
| document.getElementById('regen-section').style.display = 'block'; | |
| } | |
| } | |
| function addClip(idx) { | |
| const clip = renderedClips[idx]; | |
| const grid = document.getElementById('clips-grid'); | |
| const card = document.createElement('div'); | |
| card.className = 'clip-card'; | |
| card.dataset.clipName = clip.name; | |
| const posterAttr = clip.poster_url ? ` poster="${escapeHtml(clip.poster_url)}"` : ''; | |
| card.innerHTML = `<div class="clip-thumb"><video src="${escapeHtml(clip.url)}#t=0.35"${posterAttr} muted playsinline preload="auto" loop></video><div class="clip-play">▶</div><div class="clip-skim"><div class="clip-skim-fill"></div></div></div><div class="clip-meta"><div class="clip-num">Clip ${idx + 1}</div><div class="clip-dur">${escapeHtml(clip.duration || '0:00')}</div><a class="clip-download" href="${escapeHtml(clip.url)}" download onclick="event.stopPropagation()">Download</a></div>`; | |
| wireClipPreview(card); | |
| card.onclick = () => openModal(idx); | |
| grid.appendChild(card); | |
| } | |
| function updateClipCard(idx) { | |
| const clip = renderedClips[idx]; | |
| const card = document.querySelector(`.clip-card[data-clip-name="${CSS.escape(clip.name)}"]`); | |
| if (!card) return; | |
| const duration = card.querySelector('.clip-dur'); | |
| const preview = card.querySelector('video'); | |
| if (duration) duration.textContent = clip.duration || '0:00'; | |
| if (preview && clip.poster_url && preview.getAttribute('poster') !== clip.poster_url) { | |
| preview.setAttribute('poster', clip.poster_url); | |
| } | |
| } | |
| function wireClipPreview(card) { | |
| const preview = card.querySelector('video'); | |
| const thumb = card.querySelector('.clip-thumb'); | |
| const skimFill = card.querySelector('.clip-skim-fill'); | |
| let scrubRaf = 0; | |
| let pendingTime = null; | |
| function duration() { | |
| return Number.isFinite(preview.duration) && preview.duration > 0.2 ? preview.duration : 0; | |
| } | |
| function setSkim(percent) { | |
| if (skimFill) skimFill.style.width = `${Math.max(0, Math.min(1, percent)) * 100}%`; | |
| } | |
| function seekFromPointer(event) { | |
| const total = duration(); | |
| if (!total) return; | |
| const rect = thumb.getBoundingClientRect(); | |
| const pct = Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width))); | |
| pendingTime = Math.max(0.05, Math.min(total - 0.05, total * pct)); | |
| setSkim(pct); | |
| if (!scrubRaf) { | |
| scrubRaf = requestAnimationFrame(() => { | |
| if (pendingTime !== null && Math.abs(preview.currentTime - pendingTime) > 0.08) { | |
| preview.currentTime = pendingTime; | |
| } | |
| scrubRaf = 0; | |
| }); | |
| } | |
| } | |
| preview.addEventListener('timeupdate', () => { | |
| const total = duration(); | |
| if (total) setSkim(preview.currentTime / total); | |
| }); | |
| preview.addEventListener('loadeddata', () => card.classList.add('has-preview')); | |
| thumb.addEventListener('mouseenter', event => { | |
| card.classList.add('previewing'); | |
| seekFromPointer(event); | |
| preview.play().catch(() => {}); | |
| }); | |
| thumb.addEventListener('mousemove', seekFromPointer); | |
| thumb.addEventListener('mouseleave', () => { | |
| card.classList.remove('previewing'); | |
| preview.pause(); | |
| setSkim(0); | |
| const resetTime = Math.min(0.35, duration() || 0.35); | |
| try { preview.currentTime = resetTime; } catch (_) {} | |
| }); | |
| } | |
| function openModal(idx) { | |
| const clip = renderedClips[idx]; | |
| const modal = document.getElementById('modal'); | |
| const video = document.getElementById('modal-video'); | |
| video.className = 'modal-video'; | |
| video.innerHTML = `<video src="${clip.url}" controls autoplay playsinline></video>`; | |
| document.getElementById('modal-label').textContent = `Clip ${idx + 1}`; | |
| document.getElementById('modal-download').href = clip.url; | |
| modal.classList.add('open'); | |
| } | |
| function closeModal(e) { | |
| if (e.target === document.getElementById('modal')) { | |
| document.getElementById('modal').classList.remove('open'); | |
| document.getElementById('modal-video').innerHTML = ''; | |
| } | |
| } | |
| function setChip(text) { | |
| const ta = document.getElementById('regen-prompt'); | |
| ta.value = text; | |
| ta.focus(); | |
| } | |
| async function triggerRegen() { | |
| const prompt = document.getElementById('regen-prompt').value.trim(); | |
| if (!prompt) { document.getElementById('regen-prompt').focus(); return; } | |
| if (!currentJobId) { alert('Run a video first.'); return; } | |
| renderedClips = []; | |
| document.getElementById('clips-grid').innerHTML = ''; | |
| document.getElementById('clips-section').style.display = 'none'; | |
| document.getElementById('regen-section').style.display = 'none'; | |
| document.getElementById('nav-status').textContent = 'Regenerating...'; | |
| document.getElementById('new-session-btn').classList.remove('show'); | |
| document.querySelectorAll('.pipeline-step').forEach((s, i) => { | |
| s.classList.remove('active', 'done'); | |
| s.querySelector('.step-icon').innerHTML = iconLabels[i]; | |
| document.getElementById(`fill-${i}`).style.width = '0%'; | |
| document.getElementById(`pct-${i}`).textContent = ''; | |
| }); | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| try { | |
| const job = await createJob(prompt); | |
| rememberJob(job.id); | |
| setPollStatus('Connected - waiting for progress'); | |
| syncJob(job); | |
| pollJob(job.id); | |
| } catch (err) { | |
| alert(err.message || err); | |
| } | |
| } | |
| async function resumeStoredJob() { | |
| let id = ''; | |
| try { id = localStorage.getItem(JOB_STORAGE_KEY) || ''; } catch (_) {} | |
| if (id) { | |
| setPollStatus('Resuming previous job...'); | |
| pollJob(id, { immediate: true }); | |
| return; | |
| } | |
| try { | |
| const res = await fetch(`/api/jobs?t=${Date.now()}`, { cache: 'no-store' }); | |
| if (!res.ok) return; | |
| const data = await res.json(); | |
| const active = (data.jobs || []).find(job => !job.done); | |
| if (active && active.id) { | |
| setPollStatus('Resuming active job...'); | |
| pollJob(active.id, { immediate: true }); | |
| } | |
| } catch (_) {} | |
| } | |
| window.addEventListener('DOMContentLoaded', resumeStoredJob); | |
| </script> | |
| </body> | |
| </html>""" | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "7860"))) | |