返回 VideoClaw
pipelines.py
根目录 / video-claw / video-claw / backend / api / routers / pipelines.py
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
307 lines PYTHON