返回 VideoClaw
sandbox.py
1 import json
2 import logging
3 import os
4 import threading
5 import uuid
6 from datetime import datetime
7 from typing import List
8
9 from fastapi import APIRouter
10 from fastapi.concurrency import run_in_threadpool
11
12 from api.schemas.sandbox import (
13 SandboxI2IRequest,
14 SandboxLLMRequest,
15 SandboxT2IRequest,
16 SandboxVLMRequest,
17 SandboxVideoRequest,
18 )
19 from config import settings
20
21 router = APIRouter(tags=["Sandbox"])
22 logger = logging.getLogger(__name__)
23
24
25 SANDBOX_DIR = os.path.join(settings.CODE_DIR, "result", "sandbox")
26 SANDBOX_HISTORY_FILE = os.path.join(SANDBOX_DIR, "history.json")
27 SANDBOX_ACTIVE_TASKS: dict[str, dict] = {}
28 SANDBOX_LOCK = threading.RLock()
29
30 # 确保目录存在
31 os.makedirs(SANDBOX_DIR, exist_ok=True)
32
33
34 def _load_history() -> List[dict]:
35 """加载历史记录"""
36 with SANDBOX_LOCK:
37 if os.path.exists(SANDBOX_HISTORY_FILE):
38 try:
39 with open(SANDBOX_HISTORY_FILE, 'r', encoding='utf-8') as f:
40 return json.load(f)
41 except Exception:
42 logger.warning("Failed to load sandbox history: %s", SANDBOX_HISTORY_FILE, exc_info=True)
43 return []
44 return []
45
46
47 def _save_history(history: List[dict]):
48 """保存历史记录"""
49 with SANDBOX_LOCK:
50 tmp_path = f"{SANDBOX_HISTORY_FILE}.{uuid.uuid4().hex}.tmp"
51 with open(tmp_path, 'w', encoding='utf-8') as f:
52 json.dump(history, f, ensure_ascii=False, indent=2)
53 os.replace(tmp_path, SANDBOX_HISTORY_FILE)
54
55
56 def _normalize_path(path: str) -> str:
57 """将绝对路径转换为相对路径格式 result/..."""
58 if not path:
59 return path
60 # 如果已经是相对路径,直接返回
61 if not path.startswith('/'):
62 # 确保以 result/ 开头
63 if not path.startswith('result/'):
64 return f"result/{path}"
65 return path
66 # 绝对路径,提取相对于 CODE_DIR 的部分
67 code_dir = settings.CODE_DIR
68 if path.startswith(code_dir):
69 relative = path[len(code_dir):].lstrip('/')
70 # 直接返回 result/... 格式,因为 /code/ 会映射到 CODE_DIR
71 return relative
72 # 其他绝对路径,尝试提取文件名
73 return path.split('/')[-1]
74
75
76 def _convert_output_paths(output_data: dict) -> dict:
77 """转换 output 中的路径为相对路径格式"""
78 if not output_data:
79 return output_data
80 converted = output_data.copy()
81 # 转换 images
82 if 'images' in converted and isinstance(converted['images'], list):
83 converted['images'] = [_normalize_path(img) for img in converted['images']]
84 # 转换 video_path
85 if 'video_path' in converted and converted['video_path']:
86 converted['video_path'] = _normalize_path(converted['video_path'])
87 # 转换 input 中的 reference_image
88 if 'reference_image' in converted.get('input', {}):
89 input_copy = converted['input'].copy()
90 input_copy['reference_image'] = _normalize_path(input_copy['reference_image'])
91 converted['input'] = input_copy
92 return converted
93
94
95 def _converted_result_list(paths: List[str] | None) -> List[str]:
96 """Return generated image paths in the same format used by sandbox history."""
97 converted = _convert_output_paths({"images": paths or []})
98 return converted.get("images", [])
99
100
101 def _converted_video_path(path: str | None) -> str:
102 """Return generated video path in the same format used by sandbox history."""
103 converted = _convert_output_paths({"video_path": path or ""})
104 return converted.get("video_path", "")
105
106
107 def _add_record(
108 tool: str,
109 model: str,
110 input_data: dict,
111 output_data: dict,
112 files: List[str] = None,
113 record_id: str | None = None,
114 ) -> str:
115 """添加历史记录"""
116 with SANDBOX_LOCK:
117 record_id = record_id or str(uuid.uuid4().hex[:8])
118 # 转换路径为相对路径格式
119 output_data = _convert_output_paths(output_data)
120 record = {
121 "id": record_id,
122 "tool": tool,
123 "model": model,
124 "input": input_data,
125 "output": output_data,
126 "files": files or [],
127 "created_at": datetime.now().isoformat(),
128 }
129 history = _load_history()
130 history.insert(0, record) # 最新记录放在最前面
131 _save_history(history)
132 return record_id
133
134
135 def _start_active_task(tool: str, model: str, input_data: dict) -> str:
136 with SANDBOX_LOCK:
137 task_id = str(uuid.uuid4().hex[:8])
138 SANDBOX_ACTIVE_TASKS[task_id] = {
139 "id": task_id,
140 "tool": tool,
141 "model": model,
142 "input": input_data,
143 "status": "running",
144 "progress": 1,
145 "created_at": datetime.now().isoformat(),
146 }
147 return task_id
148
149
150 def _finish_active_task(task_id: str) -> None:
151 with SANDBOX_LOCK:
152 SANDBOX_ACTIVE_TASKS.pop(task_id, None)
153
154
155 def _delete_record_files(files: List[str]):
156 """删除记录关联的文件"""
157 for f in files:
158 if f and os.path.exists(f):
159 try:
160 os.remove(f)
161 except Exception:
162 logger.warning("Failed to delete sandbox artifact: %s", f, exc_info=True)
163 pass
164
165
166 # 请求模型
167 @router.get("/api/sandbox/history")
168 async def sandbox_get_history():
169 """获取历史记录列表"""
170 history = _load_history()
171 # 返回完整信息(包括 output)
172 return {
173 "success": True,
174 "records": [
175 {
176 "id": r["id"],
177 "tool": r["tool"],
178 "model": r["model"],
179 "input": r["input"],
180 "output": r.get("output"),
181 "created_at": r["created_at"],
182 }
183 for r in history
184 ]
185 }
186
187
188 @router.get("/api/sandbox/tasks")
189 async def sandbox_get_active_tasks():
190 """获取临时工作台正在执行的任务"""
191 with SANDBOX_LOCK:
192 tasks = list(SANDBOX_ACTIVE_TASKS.values())
193 return {"success": True, "tasks": tasks}
194
195
196 @router.get("/api/sandbox/history/{record_id}")
197 async def sandbox_get_record(record_id: str):
198 """获取单条历史记录详情"""
199 history = _load_history()
200 for r in history:
201 if r["id"] == record_id:
202 return {"success": True, "record": r}
203 return {"success": False, "error": "记录不存在"}
204
205
206 @router.delete("/api/sandbox/history/{record_id}")
207 async def sandbox_delete_record(record_id: str):
208 """删除历史记录"""
209 with SANDBOX_LOCK:
210 history = _load_history()
211 record_to_delete = None
212 new_history = []
213 for r in history:
214 if r["id"] == record_id:
215 record_to_delete = r
216 else:
217 new_history.append(r)
218
219 if record_to_delete is None:
220 return {"success": False, "error": "记录不存在"}
221
222 _save_history(new_history)
223
224 # 删除关联文件不需要占用历史锁。
225 _delete_record_files(record_to_delete.get("files", []))
226 logger.info("Sandbox history deleted: record_id=%s", record_id)
227 return {"success": True}
228
229
230 @router.post("/api/sandbox/llm")
231 async def sandbox_llm(req: SandboxLLMRequest):
232 """临时工作台 - LLM 文字生成"""
233 from models.llm_client import LLM
234 client = LLM()
235 input_data = {"prompt": req.prompt, "web_search": req.web_search}
236 task_id = _start_active_task("llm", req.model, input_data)
237 try:
238 logger.info("Sandbox LLM started: model=%s web_search=%s", req.model, req.web_search)
239 result = await run_in_threadpool(
240 client.query,
241 req.prompt,
242 model=req.model,
243 web_search=req.web_search,
244 )
245 # ��存到历史记录
246 record_id = _add_record(
247 tool="llm",
248 model=req.model,
249 input_data=input_data,
250 output_data={"response": result},
251 record_id=task_id,
252 )
253 logger.info("Sandbox LLM completed: model=%s record_id=%s", req.model, record_id)
254 return {"success": True, "result": result, "record_id": record_id}
255 except Exception as e:
256 logger.exception("Sandbox LLM failed: model=%s", req.model)
257 return {"success": False, "error": str(e)}
258 finally:
259 _finish_active_task(task_id)
260
261
262 @router.post("/api/sandbox/vlm")
263 async def sandbox_vlm(req: SandboxVLMRequest):
264 """临时工作台 - VLM 图片理解"""
265 from models.vlm_client import VLM
266 client = VLM()
267 input_data = {"prompt": req.prompt, "images": req.images}
268 task_id = _start_active_task("vlm", req.model, input_data)
269 try:
270 logger.info("Sandbox VLM started: model=%s images=%d", req.model, len(req.images or []))
271 result = await run_in_threadpool(
272 client.query,
273 req.prompt,
274 image_paths=req.images,
275 model=req.model,
276 )
277 # 保存到历史记录
278 record_id = _add_record(
279 tool="vlm",
280 model=req.model,
281 input_data=input_data,
282 output_data={"response": result},
283 record_id=task_id,
284 )
285 logger.info("Sandbox VLM completed: model=%s record_id=%s", req.model, record_id)
286 return {"success": True, "result": result, "record_id": record_id}
287 except Exception as e:
288 logger.exception("Sandbox VLM failed: model=%s", req.model)
289 return {"success": False, "error": str(e)}
290 finally:
291 _finish_active_task(task_id)
292
293
294 @router.post("/api/sandbox/t2i")
295 async def sandbox_t2i(req: SandboxT2IRequest):
296 """临时工作台 - 文生图"""
297 from models.image_client import ImageClient
298 client = ImageClient()
299 input_data = {"prompt": req.prompt, "style": req.style, "ratio": req.ratio}
300 task_id = _start_active_task("t2i", req.model, input_data)
301 try:
302 logger.info("Sandbox T2I started: model=%s ratio=%s", req.model, req.ratio)
303 result = await run_in_threadpool(
304 client.generate_image,
305 req.prompt,
306 model=req.model,
307 image_paths=None,
308 video_ratio=req.ratio,
309 )
310 # result 是图片路径列表
311 # 保存到历史记录
312 record_id = _add_record(
313 tool="t2i",
314 model=req.model,
315 input_data=input_data,
316 output_data={"images": result},
317 files=result if isinstance(result, list) else [],
318 record_id=task_id,
319 )
320 logger.info(
321 "Sandbox T2I completed: model=%s record_id=%s images=%d",
322 req.model,
323 record_id,
324 len(result) if isinstance(result, list) else 0,
325 )
326 return {
327 "success": True,
328 "result": _converted_result_list(result if isinstance(result, list) else []),
329 "record_id": record_id,
330 }
331 except Exception as e:
332 logger.exception("Sandbox T2I failed: model=%s", req.model)
333 return {"success": False, "error": str(e)}
334 finally:
335 _finish_active_task(task_id)
336
337
338 @router.post("/api/sandbox/i2i")
339 async def sandbox_i2i(req: SandboxI2IRequest):
340 """临时工作台 - 图生图"""
341 from models.image_client import ImageClient
342 client = ImageClient()
343 input_data = {"prompt": req.prompt, "reference_image": req.image}
344 task_id = _start_active_task("i2i", req.model, input_data)
345 try:
346 logger.info("Sandbox I2I started: model=%s ratio=%s", req.model, req.ratio)
347 result = await run_in_threadpool(
348 client.generate_image,
349 req.prompt,
350 image_paths=[req.image],
351 model=req.model,
352 video_ratio=req.ratio,
353 )
354 # 保存到历史记录
355 record_id = _add_record(
356 tool="i2i",
357 model=req.model,
358 input_data=input_data,
359 output_data={"images": result},
360 files=result if isinstance(result, list) else [],
361 record_id=task_id,
362 )
363 logger.info(
364 "Sandbox I2I completed: model=%s record_id=%s images=%d",
365 req.model,
366 record_id,
367 len(result) if isinstance(result, list) else 0,
368 )
369 return {
370 "success": True,
371 "result": _converted_result_list(result if isinstance(result, list) else []),
372 "record_id": record_id,
373 }
374 except Exception as e:
375 logger.exception("Sandbox I2I failed: model=%s", req.model)
376 return {"success": False, "error": str(e)}
377 finally:
378 _finish_active_task(task_id)
379
380
381 @router.post("/api/sandbox/video")
382 async def sandbox_video(req: SandboxVideoRequest):
383 """临时工作台 - 视频生成"""
384 from models.video_client import VideoClient
385 client = VideoClient()
386 duration = int(req.duration or 5)
387 input_data = {
388 "prompt": req.prompt,
389 "reference_image": req.image,
390 "ratio": req.ratio,
391 "resolution": req.resolution,
392 "duration": duration,
393 }
394 task_id = _start_active_task("video", req.model, input_data)
395 try:
396 # 生成唯一的保存路径
397 save_dir = os.path.join(SANDBOX_DIR, "videos")
398 os.makedirs(save_dir, exist_ok=True)
399 save_path = os.path.join(save_dir, f"{uuid.uuid4().hex[:8]}.mp4")
400 logger.info(
401 "Sandbox video started: model=%s image=%s ratio=%s resolution=%s duration=%ss",
402 req.model,
403 bool(req.image),
404 req.ratio,
405 req.resolution,
406 duration,
407 )
408
409 result = await run_in_threadpool(
410 client.generate_video,
411 prompt=req.prompt,
412 image_path=req.image or "",
413 save_path=save_path,
414 model=req.model,
415 duration=duration,
416 shot_type="multi",
417 video_ratio=req.ratio or "16:9",
418 resolution=req.resolution or "720P",
419 )
420 # 保存到历史记录
421 record_id = _add_record(
422 tool="video",
423 model=req.model,
424 input_data=input_data,
425 output_data={"video": result, "video_path": save_path},
426 files=[save_path],
427 record_id=task_id,
428 )
429 logger.info("Sandbox video completed: model=%s record_id=%s video=%s", req.model, record_id, save_path)
430 return {
431 "success": True,
432 "result": result,
433 "video_path": _converted_video_path(save_path),
434 "record_id": record_id,
435 }
436 except Exception as e:
437 logger.exception("Sandbox video failed: model=%s", req.model)
438 return {"success": False, "error": str(e)}
439 finally:
440 _finish_active_task(task_id)
441
441 lines PYTHON