Spaces:
Sleeping
Sleeping
| """Creator Vision API. | |
| Phase 1: POST /analyze (BackgroundTasks + in-memory job store) and | |
| GET /status/{job_id}. The analysis is currently stubbed (see analysis.py); | |
| Phase 2 wires real SAM 3. See DesignDoc.md for the result schema. | |
| """ | |
| import json | |
| import os | |
| import shutil | |
| import uuid | |
| from pathlib import Path | |
| from dotenv import load_dotenv | |
| from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from .analysis import ( | |
| BuildOpts, | |
| ProductInput, | |
| owlv2_needs_images, | |
| requires_name, | |
| requires_reference, | |
| run_analysis, | |
| ) | |
| from .jobs import store | |
| from .schemas import DetectionMode, JobResponse, StatusResponse | |
| # Load backend/.env (and fall back to repo-root .env). | |
| load_dotenv() | |
| UPLOAD_DIR = Path(__file__).resolve().parent.parent / "uploads" | |
| UPLOAD_DIR.mkdir(exist_ok=True) | |
| app = FastAPI(title="Creator Vision API", version="0.1.0") | |
| # Allowed browser origins. Defaults to the local Next.js dev server; in prod set | |
| # ALLOWED_ORIGINS to your deployed frontend URL(s), comma-separated. | |
| _origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=[o.strip() for o in _origins.split(",") if o.strip()], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| def health() -> dict: | |
| """Liveness + config sanity check (does the SAM3 key exist?).""" | |
| return { | |
| "status": "ok", | |
| "fal_key_configured": bool(os.getenv("FAL_KEY")), | |
| } | |
| def _save_upload(upload: UploadFile, dest_dir: Path) -> str: | |
| """Persist an UploadFile under dest_dir with a collision-proof name.""" | |
| suffix = Path(upload.filename or "").suffix | |
| dest = dest_dir / f"{uuid.uuid4().hex}{suffix}" | |
| with dest.open("wb") as f: | |
| shutil.copyfileobj(upload.file, f) | |
| return str(dest) | |
| def analyze( | |
| background_tasks: BackgroundTasks, | |
| video: UploadFile = File(...), | |
| # `products` is JSON: [{"name": str, "image_count": int}, ...]. `exemplars` is a | |
| # flat file list in product order, sliced back per product by `image_count`. | |
| products: str = Form(...), | |
| exemplars: list[UploadFile] = File(default=[]), | |
| caption: str = Form(default=""), | |
| mention_keywords: str = Form(default=""), | |
| mode: DetectionMode = Form(default=DetectionMode.sam3_text), | |
| split_on_cut: bool = Form(default=False), | |
| dino_variant: str = Form(default="v2"), | |
| owl_ref_type: str = Form(default="text"), # owlv2: text | image | both | |
| owl_dino: str = Form(default="none"), # owlv2 DINO-on-top: none | v2 | v3 | |
| ) -> JobResponse: | |
| """Accept the uploads, kick off background analysis, return a job id.""" | |
| if not video.filename: | |
| raise HTTPException(status_code=400, detail="A video file is required.") | |
| try: | |
| product_meta = json.loads(products) | |
| assert isinstance(product_meta, list) and product_meta | |
| except (json.JSONDecodeError, AssertionError): | |
| raise HTTPException(status_code=400, detail="`products` must be a non-empty JSON list.") | |
| job = store.create() | |
| job_dir = UPLOAD_DIR / job.id | |
| job_dir.mkdir(parents=True, exist_ok=True) | |
| video_path = _save_upload(video, job_dir) | |
| exemplar_files = [ex for ex in exemplars if ex.filename] | |
| # Slice the flat exemplar list back into per-product groups by image_count. | |
| product_inputs: list[ProductInput] = [] | |
| cursor = 0 | |
| for p in product_meta: | |
| name = str(p.get("name", "")).strip() | |
| count = int(p.get("image_count", 0)) | |
| group = exemplar_files[cursor : cursor + count] | |
| cursor += count | |
| paths = [_save_upload(ex, job_dir) for ex in group] | |
| product_inputs.append(ProductInput(name=name, exemplar_paths=paths)) | |
| # Validation is driven by the mode registry (single source of truth). | |
| opts = BuildOpts(dino_variant=dino_variant, owl_ref_type=owl_ref_type, owl_dino=owl_dino) | |
| if requires_name(mode) and any(not p.name for p in product_inputs): | |
| raise HTTPException(status_code=400, detail="Every product needs a name in this mode.") | |
| if (requires_reference(mode) or owlv2_needs_images(mode, opts)) and any( | |
| not p.exemplar_paths for p in product_inputs | |
| ): | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"{mode.value} mode requires a reference image for every product.", | |
| ) | |
| variant = dino_variant if dino_variant in ("v2", "v3") else "v2" | |
| owl_dino_v = owl_dino if owl_dino in ("none", "v2", "v3") else "none" | |
| owl_ref = owl_ref_type if owl_ref_type in ("text", "image", "both") else "text" | |
| background_tasks.add_task( | |
| run_analysis, job.id, video_path, product_inputs, caption, | |
| mention_keywords, mode, split_on_cut, variant, owl_ref, owl_dino_v, | |
| ) | |
| return JobResponse(job_id=job.id) | |
| def status(job_id: str) -> StatusResponse: | |
| job = store.get(job_id) | |
| if job is None: | |
| raise HTTPException(status_code=404, detail="Unknown job_id.") | |
| return StatusResponse( | |
| job_id=job.id, status=job.status, result=job.result, error=job.error | |
| ) | |