| 1 | import base64 |
| 2 | import os |
| 3 | import re |
| 4 | from html import escape |
| 5 | from urllib.parse import quote |
| 6 | from typing import Optional |
| 7 | |
| 8 | from fastapi import APIRouter, BackgroundTasks, HTTPException, Query |
| 9 | from fastapi.responses import HTMLResponse, StreamingResponse |
| 10 | |
| 11 | from api.schemas.pipelines import ( |
| 12 | ActionTransferPipelineRequest, |
| 13 | DigitalHumanPipelineRequest, |
| 14 | GenericPipelineRequest, |
| 15 | StandardPipelineRequest, |
| 16 | ) |
| 17 | from config import BASE_DIR |
| 18 | from models.config_model import get_models_by_type, model_type_capabilities |
| 19 | from pipelines.api_media import list_api_workflows |
| 20 | from pipelines.events import task_event_stream |
| 21 | from pipelines.runner import PIPELINE_REGISTRY, run_pipeline_task |
| 22 | from pipelines.storage import create_task, delete_task, list_tasks, load_task |
| 23 | from pipelines.utils import TEMPLATE_FIELD_DEFAULTS, template_custom_fields, template_media_spec |
| 24 | |
| 25 | router = APIRouter(tags=["Pipelines"]) |
| 26 | |
| 27 | TEMPLATE_DIR = os.path.join(str(BASE_DIR), "templates") |
| 28 | DEMO_IMAGE_PATH = os.path.join(TEMPLATE_DIR, "demo", "default_image.png") |
| 29 | TEMPLATE_SIZES = { |
| 30 | "1080x1920": {"ratio": "9:16", "width": 1080, "height": 1920}, |
| 31 | "1080x1080": {"ratio": "1:1", "width": 1080, "height": 1080}, |
| 32 | "1920x1080": {"ratio": "16:9", "width": 1920, "height": 1080}, |
| 33 | } |
| 34 | PLACEHOLDER_IMAGE_FALLBACK = ( |
| 35 | "data:image/svg+xml;utf8," |
| 36 | "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'>" |
| 37 | "<defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>" |
| 38 | "<stop stop-color='%2388b7ff'/><stop offset='1' stop-color='%23f8d58a'/>" |
| 39 | "</linearGradient></defs>" |
| 40 | "<rect width='1024' height='1024' fill='url(%23g)'/>" |
| 41 | "<circle cx='760' cy='220' r='110' fill='%23ffffff' fill-opacity='.45'/>" |
| 42 | "<path d='M90 820 360 520l170 170 130-150 270 280Z' fill='%23ffffff' fill-opacity='.55'/>" |
| 43 | "</svg>" |
| 44 | ) |
| 45 | |
| 46 | |
| 47 | def _demo_image_data_uri() -> str: |
| 48 | if os.path.exists(DEMO_IMAGE_PATH): |
| 49 | with open(DEMO_IMAGE_PATH, "rb") as f: |
| 50 | encoded = base64.b64encode(f.read()).decode("ascii") |
| 51 | return f"data:image/png;base64,{encoded}" |
| 52 | return PLACEHOLDER_IMAGE_FALLBACK |
| 53 | |
| 54 | |
| 55 | def _template_label(filename: str) -> str: |
| 56 | name = os.path.splitext(filename)[0] |
| 57 | for prefix in ("image_", "static_", "video_", "asset_"): |
| 58 | if name.startswith(prefix): |
| 59 | name = name[len(prefix):] |
| 60 | break |
| 61 | return name.replace("_", " ").title() |
| 62 | |
| 63 | |
| 64 | def _template_path(size: str, filename: str) -> str: |
| 65 | if size not in TEMPLATE_SIZES or "/" in filename or "\\" in filename or not filename.endswith(".html"): |
| 66 | raise HTTPException(404, "Template not found") |
| 67 | path = os.path.abspath(os.path.join(TEMPLATE_DIR, size, filename)) |
| 68 | root = os.path.abspath(os.path.join(TEMPLATE_DIR, size)) |
| 69 | if not path.startswith(root + os.sep) or not os.path.exists(path): |
| 70 | raise HTTPException(404, "Template not found") |
| 71 | return path |
| 72 | |
| 73 | |
| 74 | def _render_preview_html(raw: str) -> str: |
| 75 | demo_image = _demo_image_data_uri() |
| 76 | replacements = { |
| 77 | **TEMPLATE_FIELD_DEFAULTS, |
| 78 | "image": demo_image, |
| 79 | "media": f'<img class="template-media" style="width:100%;height:100%;object-fit:cover;display:block;" src="{demo_image}" alt="">', |
| 80 | } |
| 81 | |
| 82 | def repl(match: re.Match) -> str: |
| 83 | token = match.group(1).strip() |
| 84 | key = token.split(":", 1)[0].split("=", 1)[0].strip() |
| 85 | if key in replacements: |
| 86 | if key == "media": |
| 87 | return replacements[key] |
| 88 | return replacements[key] |
| 89 | if "=" in token: |
| 90 | return escape(token.split("=", 1)[1].strip()) |
| 91 | return "" |
| 92 | |
| 93 | return re.sub(r"\{\{\s*([^{}]+?)\s*\}\}", repl, raw) |
| 94 | |
| 95 | |
| 96 | def _start_task(background_tasks: BackgroundTasks, pipeline: str, params: dict): |
| 97 | if pipeline not in PIPELINE_REGISTRY: |
| 98 | raise HTTPException(404, f"Pipeline not found: {pipeline}") |
| 99 | metadata = create_task(pipeline=pipeline, input_params=params) |
| 100 | background_tasks.add_task(run_pipeline_task, metadata["task_id"], pipeline, params) |
| 101 | return { |
| 102 | "task_id": metadata["task_id"], |
| 103 | "pipeline": pipeline, |
| 104 | "status": metadata["status"], |
| 105 | "metadata_url": f"/api/tasks/{metadata['task_id']}", |
| 106 | "output_dir": metadata["output_dir"], |
| 107 | } |
| 108 | |
| 109 | |
| 110 | @router.get("/api/pipelines") |
| 111 | async def get_pipelines(): |
| 112 | return { |
| 113 | "pipelines": [ |
| 114 | { |
| 115 | "id": "standard", |
| 116 | "aliases": ["quick_create"], |
| 117 | "name": "Artistic Short Video", |
| 118 | "description": "Split narration by periods, generate one image per segment, and assemble either an image-concat short video or dynamic image-to-video clips.", |
| 119 | }, |
| 120 | { |
| 121 | "id": "action_transfer", |
| 122 | "name": "Action Transfer", |
| 123 | "description": "Use an image, a reference video, and a prompt to call an API video-edit/action-transfer model.", |
| 124 | }, |
| 125 | { |
| 126 | "id": "digital_human", |
| 127 | "name": "Digital Human", |
| 128 | "description": "Generate a talking-head/product-promotion video with API reference-to-video models.", |
| 129 | }, |
| 130 | ] |
| 131 | } |
| 132 | |
| 133 | |
| 134 | @router.get("/api/pipelines/api-workflows") |
| 135 | async def get_api_workflows( |
| 136 | media_type: Optional[str] = Query(None, pattern="^(image|video)$"), |
| 137 | ability: Optional[str] = Query(None), |
| 138 | verified_only: bool = False, |
| 139 | ): |
| 140 | required = [ability] if ability else None |
| 141 | return { |
| 142 | "workflows": list_api_workflows( |
| 143 | media_type=media_type, |
| 144 | required_adapter_abilities=required, |
| 145 | verified_only=verified_only, |
| 146 | ) |
| 147 | } |
| 148 | |
| 149 | |
| 150 | @router.get("/api/models") |
| 151 | async def get_api_models( |
| 152 | media_type: Optional[str] = Query(None, pattern="^(image|video)$"), |
| 153 | model_type: Optional[str] = Query(None, pattern="^(llm|vlm|t2i|i2i|video)$"), |
| 154 | ability: Optional[str] = Query(None), |
| 155 | verified_only: bool = False, |
| 156 | ): |
| 157 | if model_type: |
| 158 | models = [] |
| 159 | for model in get_models_by_type(model_type): |
| 160 | capabilities = model_type_capabilities(model_type, model) |
| 161 | models.append({ |
| 162 | "id": model["id"], |
| 163 | "label": model.get("name") or model["id"], |
| 164 | "provider": model.get("provider"), |
| 165 | "family": model.get("family"), |
| 166 | "model_type": model_type, |
| 167 | "type": model.get("type", []), |
| 168 | "concurrency": model.get("concurrency"), |
| 169 | "ability_type": capabilities.get("ability_type"), |
| 170 | "ability_types": capabilities.get("ability_types", []), |
| 171 | "adapter_ability_types": capabilities.get("adapter_ability_types", []), |
| 172 | "input_modalities": capabilities.get("input_modalities", []), |
| 173 | "adapter_input_modalities": capabilities.get("adapter_input_modalities", []), |
| 174 | "api_contract_verified": capabilities.get("api_contract_verified", False), |
| 175 | "capabilities": capabilities, |
| 176 | }) |
| 177 | return { |
| 178 | "models": models |
| 179 | } |
| 180 | |
| 181 | required = [ability] if ability else None |
| 182 | workflows = list_api_workflows( |
| 183 | media_type=media_type, |
| 184 | required_adapter_abilities=required, |
| 185 | verified_only=verified_only, |
| 186 | ) |
| 187 | return { |
| 188 | "models": [ |
| 189 | { |
| 190 | "id": workflow["model"], |
| 191 | "label": workflow.get("display_name") or workflow["model"], |
| 192 | "provider": workflow.get("provider"), |
| 193 | "family": workflow.get("family"), |
| 194 | "media_type": workflow.get("media_type"), |
| 195 | "ability_type": workflow.get("ability_type"), |
| 196 | "ability_types": workflow.get("ability_types", []), |
| 197 | "adapter_ability_types": workflow.get("adapter_ability_types", []), |
| 198 | "input_modalities": workflow.get("input_modalities", []), |
| 199 | "adapter_input_modalities": workflow.get("adapter_input_modalities", []), |
| 200 | "api_contract_verified": workflow.get("api_contract_verified", False), |
| 201 | "capabilities": workflow.get("capabilities", {}), |
| 202 | } |
| 203 | for workflow in workflows |
| 204 | ] |
| 205 | } |
| 206 | |
| 207 | |
| 208 | @router.get("/api/pipelines/standard/templates") |
| 209 | async def get_standard_templates(): |
| 210 | templates = [] |
| 211 | for size, meta in TEMPLATE_SIZES.items(): |
| 212 | folder = os.path.join(TEMPLATE_DIR, size) |
| 213 | if not os.path.isdir(folder): |
| 214 | continue |
| 215 | for filename in sorted(os.listdir(folder)): |
| 216 | if not filename.endswith(".html"): |
| 217 | continue |
| 218 | template_id = f"{size}/{filename}" |
| 219 | try: |
| 220 | media = template_media_spec(template_id) |
| 221 | except Exception: |
| 222 | continue |
| 223 | encoded_filename = quote(filename) |
| 224 | templates.append({ |
| 225 | "id": template_id, |
| 226 | "name": os.path.splitext(filename)[0], |
| 227 | "label": _template_label(filename), |
| 228 | "size": size, |
| 229 | "ratio": meta["ratio"], |
| 230 | "width": meta["width"], |
| 231 | "height": meta["height"], |
| 232 | **media, |
| 233 | "fields": template_custom_fields(template_id), |
| 234 | "preview_url": f"/api/pipelines/standard/templates/{size}/{encoded_filename}/preview", |
| 235 | }) |
| 236 | return {"templates": templates} |
| 237 | |
| 238 | |
| 239 | @router.get("/api/pipelines/standard/templates/{size}/{filename}/preview", response_class=HTMLResponse) |
| 240 | async def preview_standard_template(size: str, filename: str): |
| 241 | path = _template_path(size, filename) |
| 242 | with open(path, "r", encoding="utf-8") as f: |
| 243 | return HTMLResponse(_render_preview_html(f.read())) |
| 244 | |
| 245 | |
| 246 | @router.post("/api/pipelines/standard/tasks") |
| 247 | async def start_standard_pipeline(req: StandardPipelineRequest, background_tasks: BackgroundTasks): |
| 248 | return _start_task(background_tasks, "standard", req.model_dump(exclude_none=True)) |
| 249 | |
| 250 | |
| 251 | @router.post("/api/pipelines/action_transfer/tasks") |
| 252 | async def start_action_transfer_pipeline(req: ActionTransferPipelineRequest, background_tasks: BackgroundTasks): |
| 253 | return _start_task(background_tasks, "action_transfer", req.model_dump(exclude_none=True)) |
| 254 | |
| 255 | |
| 256 | @router.post("/api/pipelines/digital_human/tasks") |
| 257 | async def start_digital_human_pipeline(req: DigitalHumanPipelineRequest, background_tasks: BackgroundTasks): |
| 258 | return _start_task(background_tasks, "digital_human", req.model_dump(exclude_none=True)) |
| 259 | |
| 260 | |
| 261 | @router.post("/api/pipelines/{pipeline}/tasks") |
| 262 | async def start_generic_pipeline(pipeline: str, req: GenericPipelineRequest, background_tasks: BackgroundTasks): |
| 263 | normalized = "standard" if pipeline == "quick_create" else pipeline |
| 264 | return _start_task(background_tasks, normalized, req.params) |
| 265 | |
| 266 | |
| 267 | @router.get("/api/tasks") |
| 268 | async def get_tasks(limit: int = Query(100, ge=1, le=500)): |
| 269 | return {"tasks": list_tasks(limit=limit)} |
| 270 | |
| 271 | |
| 272 | @router.get("/api/tasks/{task_id}") |
| 273 | async def get_task(task_id: str): |
| 274 | metadata = load_task(task_id) |
| 275 | if not metadata: |
| 276 | raise HTTPException(404, "Task not found") |
| 277 | return metadata |
| 278 | |
| 279 | |
| 280 | @router.delete("/api/tasks/{task_id}") |
| 281 | async def remove_task(task_id: str): |
| 282 | if not delete_task(task_id): |
| 283 | raise HTTPException(404, "Task not found") |
| 284 | return {"success": True} |
| 285 | |
| 286 | |
| 287 | @router.get("/api/tasks/{task_id}/events") |
| 288 | async def subscribe_task_events(task_id: str): |
| 289 | metadata = load_task(task_id) |
| 290 | if not metadata: |
| 291 | raise HTTPException(404, "Task not found") |
| 292 | initial_event = { |
| 293 | "type": "snapshot", |
| 294 | "task_id": task_id, |
| 295 | "status": metadata.get("status"), |
| 296 | "progress": metadata.get("progress", 0), |
| 297 | } |
| 298 | return StreamingResponse( |
| 299 | task_event_stream(task_id, initial_event=initial_event), |
| 300 | media_type="text/event-stream", |
| 301 | headers={ |
| 302 | "Cache-Control": "no-cache", |
| 303 | "Connection": "keep-alive", |
| 304 | "X-Accel-Buffering": "no", |
| 305 | }, |
| 306 | ) |
| 307 |