import json import logging import os from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, status from fastapi.responses import RedirectResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.dependencies import get_db, get_current_user from app.models.user import User from app.models.project import Project from app.models.generation_record import GenerationRecord from app.schemas.generation import ( OptimizeParams, GenerateParams, GenerationRecordOut, OptimizeResult, UpdatePromptRequest, GenerationType, DURATIONS, ASPECT_RATIOS, RESOLUTIONS, IMAGE_SIZES, ) from app.services.credits import deduct_credits, calc_text_credits, calc_video_credits, calc_image_credits from app.services.llm import optimize_prompt from app.services.video_url import generate_temp_url, validate_and_get_record_id, get_video_stream_url from app.utils.id_gen import generate_id from app.utils.exceptions import InsufficientCreditsError, RecordNotFoundError, InvalidStatusError router = APIRouter(prefix="/generation-records", tags=["generation"]) logger = logging.getLogger("videogen") def _record_to_out(record: GenerationRecord, project_name: str) -> GenerationRecordOut: refs = None if record.media_references: try: refs = json.loads(record.media_references) except (json.JSONDecodeError, TypeError): refs = None error_message = record.error_message if error_message: from app.services.error_codes import ARK_ERRORS import re match = re.search(r"code='([^']+)'", error_message) if match: code = match.group(1) if code in ARK_ERRORS: error_message = ARK_ERRORS[code] else: parts = error_message.split(":") if len(parts) >= 2 and parts[1].strip() in ARK_ERRORS: error_message = ARK_ERRORS[parts[1].strip()] return GenerationRecordOut( id=record.id, project_id=record.project_id, project_name=project_name, original_prompt=record.original_prompt, optimized_prompt=record.optimized_prompt, gen_type=record.gen_type, duration=record.duration, aspect_ratio=record.aspect_ratio, resolution=record.resolution, image_size=record.image_size, image_proportion=record.image_proportion, image_px=record.image_px, status=record.status, video_url=record.video_url, image_url=record.image_url, references=refs, text_credits_cost=round(record.text_credits_cost or 0.00, 2), # text_tokens_used=record.text_tokens_used or 0, credits_cost=round(record.credits_cost or 0.00, 2), # video_tokens_used=record.video_tokens_used or 0, # image_tokens_used=record.image_tokens_used or 0, error_message=error_message, created_at=record.created_at, generated_at=record.generated_at, ) @router.get("", response_model=list[GenerationRecordOut]) async def list_records( project_id: str | None = Query(None, alias="project_id"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): query = ( select(GenerationRecord, Project.name) .join(Project, GenerationRecord.project_id == Project.id) .where(GenerationRecord.user_id == current_user.id) .order_by(GenerationRecord.created_at.desc()) ) if project_id: query = query.where(GenerationRecord.project_id == project_id) result = await db.execute(query) rows = result.all() return [ _record_to_out(record, project_name) for record, project_name in rows ] @router.post("/optimize", response_model=OptimizeResult) async def optimize( req: OptimizeParams, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): # Validate parameters based on generation type if req.gen_type == GenerationType.video: if req.duration not in DURATIONS: raise HTTPException(status_code=400, detail=f"视频时长必须为{DURATIONS}秒之一") if not req.duration: raise HTTPException(status_code=400, detail="视频生成需要指定时长") elif req.gen_type == GenerationType.image: if req.image_size not in IMAGE_SIZES: raise HTTPException(status_code=400, detail=f"图片分辨率必须为{IMAGE_SIZES}之一") if not req.image_size: raise HTTPException(status_code=400, detail="图片生成需要指定画面分辨率") # Idempotency check: if key provided, return existing record if found if req.idempotency_key: existing = await db.execute( select(GenerationRecord, Project.name) .join(Project, GenerationRecord.project_id == Project.id) .where( GenerationRecord.user_id == current_user.id, GenerationRecord.idempotency_key == req.idempotency_key, GenerationRecord.gen_type == req.gen_type, GenerationRecord.status == "prompt_optimized", ) .order_by(GenerationRecord.created_at.desc()) .limit(1) ) row = existing.first() if row: record, project_name = row return OptimizeResult( optimized_prompt=record.optimized_prompt or "", text_credits_cost=record.text_credits_cost or 0.00, text_tokens_used=record.text_tokens_used or 0, record=_record_to_out(record, project_name), ) # Check project exists and belongs to user proj_result = await db.execute( select(Project).where( Project.id == req.project_id, Project.user_id == current_user.id, ) ) project = proj_result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") # Optimize prompt via LLM with type-specific context try: optimized, token_usage = await optimize_prompt( db, req.prompt, user_id=current_user.id, industry_key=project.industry, duration=req.duration if req.gen_type == GenerationType.video else None, image_size=req.image_size if req.gen_type == GenerationType.image else None, image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None, image_px=req.image_px if req.gen_type == GenerationType.image else None, references=req.references, gen_type=req.gen_type, ) # Create record BEFORE LLM call so it's visible if user refreshes record = GenerationRecord( id=generate_id(), user_id=current_user.id, project_id=req.project_id, original_prompt=req.prompt, gen_type=req.gen_type, duration=req.duration, image_size=req.image_size, image_proportion=req.image_proportion, image_px=req.image_px, status="optimizing", credits_cost=0, text_credits_cost=0, text_tokens_used=0, media_references=json.dumps(req.references) if req.references else None, idempotency_key=req.idempotency_key, ) db.add(record) await db.flush() await db.commit() except Exception as e: from app.services.error_codes import extract_error_message # record.status = "failed" # record.error_message = extract_error_message(e, "提示词") # await db.flush() # await db.commit() raise HTTPException(status_code=502, detail=f"AI模型调用失败: {extract_error_message(e, "提示词")}") text_credits = await calc_text_credits( db, token_usage["input_tokens"], token_usage["output_tokens"], ) await deduct_credits( db, current_user.id, text_credits, f"提示词优化 - {project.name}", ) record.optimized_prompt = optimized record.status = "prompt_optimized" record.text_credits_cost = round(text_credits, 2) record.text_tokens_used = token_usage["total_tokens"] await db.flush() return OptimizeResult( optimized_prompt=optimized, text_credits_cost=round(text_credits, 2), # text_tokens_used=token_usage["total_tokens"], record=_record_to_out(record, project.name), ) @router.post("/{record_id}/generate") async def generate( record_id: str, req: GenerateParams, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(GenerationRecord, Project.name) .join(Project, GenerationRecord.project_id == Project.id) .where( GenerationRecord.id == record_id, GenerationRecord.user_id == current_user.id, ) ) row = result.first() if not row: raise RecordNotFoundError() record, project_name = row if record.status not in ("prompt_optimized", "failed"): raise InvalidStatusError("当前状态不允许生成") if record.gen_type == GenerationType.video: # Video generation if req.aspect_ratio not in ASPECT_RATIOS: raise HTTPException(status_code=400, detail="不支持的画面比例") if req.resolution not in RESOLUTIONS: raise HTTPException(status_code=400, detail="不支持的分辨率") duration = record.duration or 5 video_credits = await calc_video_credits(db, duration, req.resolution) await deduct_credits( db, current_user.id, video_credits, f"视频生成 - {project_name}", related_id=record_id, ) record.aspect_ratio = req.aspect_ratio record.resolution = req.resolution record.credits_cost = round(video_credits, 2) record.status = "generating" record.error_message = None await db.flush() try: from app.services.video_gen import get_active_engine, submit_video_task from app.services.error_codes import extract_error_message from app.services.video_queue import task_queue engine = await get_active_engine(db) task_id = await submit_video_task(db, engine, record) record.seedance_task_id = task_id await db.flush() await task_queue.enqueue(record_id) except Exception as e: record.status = "failed" record.error_message = extract_error_message(e, "视频") await db.flush() elif record.gen_type == GenerationType.image: # Image generation image_credits = await calc_image_credits(db, req.image_size or record.image_size or "2K") await deduct_credits( db, current_user.id, image_credits, f"图片生成 - {project_name}", related_id=record_id, ) record.image_size = req.image_size or record.image_size or "2K" record.credits_cost = round(image_credits, 2) record.status = "generating" record.error_message = None await db.commit() from app.services.video_queue import task_queue await task_queue.enqueue(record_id) return _record_to_out(record, project_name) @router.post("/{record_id}/retry") async def retry_generation( record_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(GenerationRecord, Project.name) .join(Project, GenerationRecord.project_id == Project.id) .where( GenerationRecord.id == record_id, GenerationRecord.user_id == current_user.id, ) ) row = result.first() if not row: raise RecordNotFoundError() record, project_name = row if record.status != "failed": raise InvalidStatusError("只有失败的记录可以重试") # Re-deduct video credits for retry if record.duration and record.resolution: video_credits = await calc_video_credits(db, record.duration, record.resolution) await deduct_credits( db, current_user.id, video_credits, f"视频重试 - {project_name}", related_id=record_id, ) record.credits_cost = round((record.credits_cost or 0) + video_credits, 2) record.status = "generating" record.error_message = None await db.flush() try: from app.services.video_gen import get_active_engine, submit_video_task, extract_error_message from app.services.video_queue import task_queue engine = await get_active_engine(db) task_id = await submit_video_task(db, engine, record) record.seedance_task_id = task_id await db.flush() await task_queue.enqueue(record_id) except Exception as e: record.status = "failed" record.error_message = extract_error_message(e) await db.flush() return _record_to_out(record, project_name) @router.put("/{record_id}/prompt") async def update_prompt( record_id: str, req: UpdatePromptRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(GenerationRecord).where( GenerationRecord.id == record_id, GenerationRecord.user_id == current_user.id, ) ) record = result.scalar_one_or_none() if not record: raise RecordNotFoundError() if record.status != "prompt_optimized": raise InvalidStatusError("只有待生成状态可以修改提示词") record.optimized_prompt = req.optimized_prompt await db.flush() return {"message": "ok"} @router.get("/{record_id}/video") async def get_video( record_id: str, token: str = Query(...), db: AsyncSession = Depends(get_db), ): """Validate temp token and redirect to video URL.""" validated_id = await validate_and_get_record_id(token) if validated_id != record_id: raise HTTPException(status_code=403, detail="无效的视频链接") video_url = await get_video_stream_url(db, record_id) if not video_url: raise HTTPException(status_code=404, detail="视频不存在") return RedirectResponse(url=video_url) @router.get("/{record_id}/queue-status") async def get_queue_status( record_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get queue position, estimated wait time, and current status for a generation record.""" from sqlalchemy import func result = await db.execute( select(GenerationRecord).where( GenerationRecord.id == record_id, GenerationRecord.user_id == current_user.id, ) ) record = result.scalar_one_or_none() if not record: raise RecordNotFoundError() queue_position = None estimated_wait_seconds = None if record.status == "generating": ahead_result = await db.execute( select(func.count(GenerationRecord.id)).where( GenerationRecord.status == "generating", GenerationRecord.created_at < record.created_at, ) ) ahead = ahead_result.scalar() or 0 queue_position = ahead + 1 estimated_wait_seconds = ahead * 60 return { "record_id": record.id, "status": record.status, "queue_position": queue_position, "estimated_wait_seconds": estimated_wait_seconds, } @router.post("/callbacks/seedance") async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)): """Receive async callback from Seedance API.""" data = await request.json() task_id = data.get("id") task_status = data.get("status") if not task_id: return {"message": "ignored"} result = await db.execute( select(GenerationRecord).where(GenerationRecord.seedance_task_id == task_id) ) record = result.scalar_one_or_none() if not record: return {"message": "record not found"} if task_status == "succeeded": remote_url = data.get("content", {}).get("video_url", "") record.status = "completed" # Download video to local storage if settings.STORAGE_TYPE == "local" and remote_url: try: from app.services.video_gen import download_video dest = os.path.join(settings.STORAGE_LOCAL_PATH, f"{record.id}.mp4") await download_video(remote_url, dest) record.video_url = f"/videos/{record.id}.mp4" except Exception as e: logger.warning(f"Callback download failed, using remote URL: {e}") record.video_url = remote_url else: record.video_url = remote_url record.generated_at = datetime.now() # Extract video token usage from callback usage = data.get("usage", {}) if usage: record.video_tokens_used = usage.get("total_tokens", 0) # Log callback response from app.services.video_gen import _log_video_response _log_video_response(record.id, data) # Notify user from app.services.notification import create_notification from app.api.v1.notifications import push_notification_to_user notif = await create_notification( db, record.user_id, "视频生成完成", "您的视频已生成完成,可以查看了。", "video", record.id, ) await push_notification_to_user(record.user_id, notif) elif task_status == "failed": record.status = "failed" record.error_message = data.get("error", "视频生成失败") # Log callback response from app.services.video_gen import _log_video_response _log_video_response(record.id, data, error=record.error_message) # Notify user from app.services.notification import create_notification from app.api.v1.notifications import push_notification_to_user notif = await create_notification( db, record.user_id, "视频生成失败", f"视频生成失败:{record.error_message}", "video", record.id, ) await push_notification_to_user(record.user_id, notif) await db.flush() return {"message": "ok"} @router.post("/upload-image") async def upload_image( file: UploadFile = File(...), current_user: User = Depends(get_current_user), gen_type: str = Query("video", description="生成类型:video-视频,image-图片"), ): """Upload an image for generation reference.""" import os import uuid from app.config import settings from datetime import datetime if not file.content_type or not file.content_type.startswith("image/"): raise HTTPException(status_code=400, detail="仅支持图片文件") ext = os.path.splitext(file.filename or ".png")[1] or ".png" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") safe_name = f"{gen_type}_img_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}" date_dir = datetime.now().strftime("%Y/%m/%d") dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "images", date_dir) os.makedirs(dir_path, exist_ok=True) file_path = os.path.join(dir_path, safe_name) content = await file.read() if len(content) > 10 * 1024 * 1024: raise HTTPException(status_code=400, detail="图片大小不能超过10MB") with open(file_path, "wb") as f: f.write(content) url = f"/uploads/images/{date_dir}/{safe_name}" return {"url": url, "filename": file.filename or safe_name, "type": "image", "gen_type": gen_type} @router.post("/upload-video") async def upload_video( file: UploadFile = File(...), current_user: User = Depends(get_current_user), ): """Upload a video for generation reference.""" import os import uuid from app.config import settings from datetime import datetime if not file.content_type or not file.content_type.startswith("video/"): raise HTTPException(status_code=400, detail="仅支持视频文件") ext = os.path.splitext(file.filename or ".mp4")[1] or ".mp4" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") safe_name = f"video_ref_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}" date_dir = datetime.now().strftime("%Y/%m/%d") dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "videos", date_dir) os.makedirs(dir_path, exist_ok=True) file_path = os.path.join(dir_path, safe_name) content = await file.read() if len(content) > 100 * 1024 * 1024: raise HTTPException(status_code=400, detail="视频大小不能超过100MB") with open(file_path, "wb") as f: f.write(content) url = f"/uploads/videos/{date_dir}/{safe_name}" return {"url": url, "filename": file.filename or safe_name, "type": "video"} @router.post("/delete-file") async def delete_upload( url: str = Query(..., description="文件URL,如 /uploads/images/2024/01/01/video_img_xxx.png"), current_user: User = Depends(get_current_user), ): """Delete an uploaded file by URL.""" import os from app.config import settings if not url.startswith("/uploads/"): raise HTTPException(status_code=400, detail="无效的文件路径") if current_user.id not in url: raise HTTPException(status_code=403, detail="无权删除此文件") file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, url.replace("/uploads/", "")) if os.path.exists(file_path): os.remove(file_path) return {"message": "ok"}