feat: Add comprehensive timeline editor with frame editing and regeneration capabilities
This commit is contained in:
427
api/routers/publish.py
Normal file
427
api/routers/publish.py
Normal file
@@ -0,0 +1,427 @@
|
||||
# Copyright (C) 2025 AIDC-AI
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Publish API router for multi-platform video distribution.
|
||||
|
||||
Endpoints:
|
||||
- POST /publish/export - Format conversion and export
|
||||
- POST /publish/bilibili - Publish to Bilibili (TODO)
|
||||
- POST /publish/youtube - Publish to YouTube (TODO)
|
||||
- GET /publish/tasks/{id} - Get task status
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Path, BackgroundTasks
|
||||
from loguru import logger
|
||||
|
||||
from api.schemas.publish import (
|
||||
PublishRequest,
|
||||
PublishResultSchema,
|
||||
PublishTaskSchema,
|
||||
PublishStatusEnum,
|
||||
PlatformRequirementsSchema,
|
||||
VideoMetadataSchema,
|
||||
)
|
||||
from pixelle_video.services.publishing import (
|
||||
VideoMetadata,
|
||||
PublishStatus,
|
||||
PublishTask,
|
||||
Platform,
|
||||
)
|
||||
from pixelle_video.services.publishing.export_publisher import ExportPublisher
|
||||
|
||||
|
||||
router = APIRouter(prefix="/publish", tags=["Publish"])
|
||||
|
||||
|
||||
# In-memory task storage (use Redis in production)
|
||||
_publish_tasks: dict = {}
|
||||
|
||||
# Publisher instances
|
||||
_export_publisher = ExportPublisher()
|
||||
|
||||
|
||||
@router.post("/export", response_model=PublishResultSchema)
|
||||
async def export_video(
|
||||
request: PublishRequest,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
Convert video to platform-optimized format and export.
|
||||
|
||||
Optimizes for:
|
||||
- Portrait 9:16 aspect ratio (1080x1920)
|
||||
- H.264 codec
|
||||
- ≤128MB file size
|
||||
|
||||
For manual upload to Douyin/Kuaishou.
|
||||
"""
|
||||
# Create task
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
|
||||
metadata = VideoMetadata(
|
||||
title=request.metadata.title,
|
||||
description=request.metadata.description,
|
||||
tags=request.metadata.tags,
|
||||
category=request.metadata.category,
|
||||
cover_path=request.metadata.cover_path,
|
||||
privacy=request.metadata.privacy,
|
||||
platform_options=request.metadata.platform_options,
|
||||
)
|
||||
|
||||
task = PublishTask(
|
||||
id=task_id,
|
||||
video_path=request.video_path,
|
||||
platform=Platform.EXPORT,
|
||||
metadata=metadata,
|
||||
status=PublishStatus.PENDING,
|
||||
)
|
||||
_publish_tasks[task_id] = task
|
||||
|
||||
logger.info(f"📤 Starting export task {task_id} for: {metadata.title}")
|
||||
|
||||
# Execute synchronously for now (can be moved to background)
|
||||
result = await _export_publisher.publish(
|
||||
request.video_path,
|
||||
metadata,
|
||||
progress_callback=lambda p, m: logger.info(f"Export {task_id}: {p:.0%} - {m}")
|
||||
)
|
||||
|
||||
# Update task
|
||||
task.status = PublishStatus(result.status.value)
|
||||
task.result = result
|
||||
task.updated_at = datetime.now()
|
||||
|
||||
return PublishResultSchema(
|
||||
success=result.success,
|
||||
platform="export",
|
||||
status=PublishStatusEnum(result.status.value),
|
||||
export_path=result.export_path,
|
||||
error_message=result.error_message,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=PublishTaskSchema)
|
||||
async def get_publish_task(task_id: str = Path(..., description="Task ID")):
|
||||
"""Get publishing task status"""
|
||||
if task_id not in _publish_tasks:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
task = _publish_tasks[task_id]
|
||||
|
||||
return PublishTaskSchema(
|
||||
id=task.id,
|
||||
platform=task.platform.value,
|
||||
status=PublishStatusEnum(task.status.value),
|
||||
result=PublishResultSchema(
|
||||
success=task.result.success,
|
||||
platform=task.result.platform.value,
|
||||
status=PublishStatusEnum(task.result.status.value),
|
||||
export_path=task.result.export_path,
|
||||
error_message=task.result.error_message,
|
||||
) if task.result else None,
|
||||
created_at=task.created_at.isoformat(),
|
||||
updated_at=task.updated_at.isoformat() if task.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/requirements/{platform}", response_model=PlatformRequirementsSchema)
|
||||
async def get_platform_requirements(platform: str = Path(..., description="Platform name")):
|
||||
"""Get platform-specific requirements"""
|
||||
|
||||
requirements = {
|
||||
"export": {
|
||||
"max_file_size_mb": 128,
|
||||
"max_duration_seconds": 900,
|
||||
"supported_formats": ["mp4"],
|
||||
"recommended_resolution": (1080, 1920),
|
||||
"recommended_codec": "h264",
|
||||
},
|
||||
"bilibili": {
|
||||
"max_file_size_mb": 4096,
|
||||
"max_duration_seconds": 14400, # 4 hours
|
||||
"supported_formats": ["mp4", "flv", "webm"],
|
||||
"recommended_resolution": (1920, 1080), # Landscape
|
||||
"recommended_codec": "h264",
|
||||
},
|
||||
"youtube": {
|
||||
"max_file_size_mb": 256000, # 256GB
|
||||
"max_duration_seconds": 43200, # 12 hours
|
||||
"supported_formats": ["mp4", "mov", "avi", "webm"],
|
||||
"recommended_resolution": (1920, 1080),
|
||||
"recommended_codec": "h264",
|
||||
},
|
||||
}
|
||||
|
||||
if platform not in requirements:
|
||||
raise HTTPException(status_code=404, detail=f"Platform {platform} not supported")
|
||||
|
||||
return requirements[platform]
|
||||
|
||||
|
||||
from pixelle_video.services.publishing.bilibili_publisher import BilibiliPublisher
|
||||
|
||||
# Bilibili publisher instance
|
||||
_bilibili_publisher = BilibiliPublisher()
|
||||
|
||||
|
||||
@router.post("/bilibili", response_model=PublishResultSchema)
|
||||
async def publish_to_bilibili(request: PublishRequest):
|
||||
"""
|
||||
Publish video to Bilibili.
|
||||
|
||||
Requires environment variables:
|
||||
- BILIBILI_ACCESS_TOKEN or
|
||||
- BILIBILI_SESSDATA + BILIBILI_BILI_JCT
|
||||
"""
|
||||
if not await _bilibili_publisher.validate_credentials():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="B站凭证未配置。请设置 BILIBILI_ACCESS_TOKEN 或 BILIBILI_SESSDATA 环境变量"
|
||||
)
|
||||
|
||||
metadata = VideoMetadata(
|
||||
title=request.metadata.title,
|
||||
description=request.metadata.description,
|
||||
tags=request.metadata.tags,
|
||||
category=request.metadata.category,
|
||||
cover_path=request.metadata.cover_path,
|
||||
privacy=request.metadata.privacy,
|
||||
platform_options=request.metadata.platform_options,
|
||||
)
|
||||
|
||||
logger.info(f"📤 Starting Bilibili upload: {metadata.title}")
|
||||
|
||||
result = await _bilibili_publisher.publish(
|
||||
request.video_path,
|
||||
metadata,
|
||||
progress_callback=lambda p, m: logger.info(f"Bilibili: {p:.0%} - {m}")
|
||||
)
|
||||
|
||||
return PublishResultSchema(
|
||||
success=result.success,
|
||||
platform="bilibili",
|
||||
status=PublishStatusEnum(result.status.value),
|
||||
video_url=result.video_url,
|
||||
platform_video_id=result.platform_video_id,
|
||||
error_message=result.error_message,
|
||||
)
|
||||
|
||||
from pixelle_video.services.publishing.youtube_publisher import YouTubePublisher
|
||||
|
||||
# YouTube publisher instance
|
||||
_youtube_publisher = YouTubePublisher()
|
||||
|
||||
|
||||
@router.post("/youtube", response_model=PublishResultSchema)
|
||||
async def publish_to_youtube(request: PublishRequest):
|
||||
"""
|
||||
Publish video to YouTube.
|
||||
|
||||
Requires:
|
||||
- config/youtube_client_secrets.json (OAuth 2.0 credentials)
|
||||
- First-time auth will open browser for authorization
|
||||
"""
|
||||
if not await _youtube_publisher.validate_credentials():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="YouTube 凭证未配置。请添加 config/youtube_client_secrets.json"
|
||||
)
|
||||
|
||||
metadata = VideoMetadata(
|
||||
title=request.metadata.title,
|
||||
description=request.metadata.description,
|
||||
tags=request.metadata.tags,
|
||||
category=request.metadata.category,
|
||||
cover_path=request.metadata.cover_path,
|
||||
privacy=request.metadata.privacy,
|
||||
platform_options=request.metadata.platform_options,
|
||||
)
|
||||
|
||||
logger.info(f"📤 Starting YouTube upload: {metadata.title}")
|
||||
|
||||
result = await _youtube_publisher.publish(
|
||||
request.video_path,
|
||||
metadata,
|
||||
progress_callback=lambda p, m: logger.info(f"YouTube: {p:.0%} - {m}")
|
||||
)
|
||||
|
||||
return PublishResultSchema(
|
||||
success=result.success,
|
||||
platform="youtube",
|
||||
status=PublishStatusEnum(result.status.value),
|
||||
video_url=result.video_url,
|
||||
platform_video_id=result.platform_video_id,
|
||||
error_message=result.error_message,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Async Task Queue Endpoints
|
||||
# ============================================================
|
||||
|
||||
from pixelle_video.services.publishing.task_manager import get_publish_manager, TaskPriority
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
|
||||
|
||||
class AsyncPublishRequest(BaseModel):
|
||||
"""Request for async publishing"""
|
||||
video_path: str
|
||||
platform: str # export, bilibili, youtube
|
||||
metadata: VideoMetadataSchema
|
||||
priority: str = "normal" # low, normal, high
|
||||
|
||||
|
||||
class QueuedTaskSchema(BaseModel):
|
||||
"""Schema for queued task"""
|
||||
id: str
|
||||
platform: str
|
||||
status: str
|
||||
progress: float
|
||||
progress_message: str
|
||||
retries: int
|
||||
created_at: str
|
||||
started_at: str = None
|
||||
completed_at: str = None
|
||||
|
||||
|
||||
class QueueStatusSchema(BaseModel):
|
||||
"""Queue status overview"""
|
||||
pending: int
|
||||
active: int
|
||||
completed: int
|
||||
failed: int
|
||||
workers: int
|
||||
|
||||
|
||||
@router.post("/async", response_model=dict)
|
||||
async def publish_async(request: AsyncPublishRequest):
|
||||
"""
|
||||
Submit a publish task to the background queue.
|
||||
|
||||
Returns immediately with task ID for tracking.
|
||||
"""
|
||||
manager = get_publish_manager()
|
||||
|
||||
# Ensure manager is running
|
||||
if not manager._running:
|
||||
await manager.start()
|
||||
|
||||
# Map platform string to enum
|
||||
platform_map = {
|
||||
"export": Platform.EXPORT,
|
||||
"bilibili": Platform.BILIBILI,
|
||||
"youtube": Platform.YOUTUBE,
|
||||
}
|
||||
|
||||
platform = platform_map.get(request.platform.lower())
|
||||
if not platform:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid platform: {request.platform}")
|
||||
|
||||
# Map priority
|
||||
priority_map = {
|
||||
"low": TaskPriority.LOW,
|
||||
"normal": TaskPriority.NORMAL,
|
||||
"high": TaskPriority.HIGH,
|
||||
}
|
||||
priority = priority_map.get(request.priority.lower(), TaskPriority.NORMAL)
|
||||
|
||||
metadata = VideoMetadata(
|
||||
title=request.metadata.title,
|
||||
description=request.metadata.description,
|
||||
tags=request.metadata.tags,
|
||||
category=request.metadata.category,
|
||||
cover_path=request.metadata.cover_path,
|
||||
privacy=request.metadata.privacy,
|
||||
platform_options=request.metadata.platform_options,
|
||||
)
|
||||
|
||||
task_id = await manager.enqueue(
|
||||
video_path=request.video_path,
|
||||
platform=platform,
|
||||
metadata=metadata,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "queued",
|
||||
"message": f"Task queued for {request.platform}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/queue/status", response_model=QueueStatusSchema)
|
||||
async def get_queue_status():
|
||||
"""Get queue status overview."""
|
||||
manager = get_publish_manager()
|
||||
|
||||
all_tasks = manager.get_all_tasks()
|
||||
|
||||
pending = sum(1 for t in all_tasks if t.task.status == PublishStatus.PENDING)
|
||||
active = len(manager.get_active_tasks())
|
||||
completed = sum(1 for t in all_tasks if t.task.status == PublishStatus.PUBLISHED)
|
||||
failed = sum(1 for t in all_tasks if t.task.status == PublishStatus.FAILED)
|
||||
|
||||
return QueueStatusSchema(
|
||||
pending=pending,
|
||||
active=active,
|
||||
completed=completed,
|
||||
failed=failed,
|
||||
workers=manager.max_workers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/queue/tasks", response_model=List[QueuedTaskSchema])
|
||||
async def list_queued_tasks():
|
||||
"""List all tasks in queue."""
|
||||
manager = get_publish_manager()
|
||||
|
||||
result = []
|
||||
for qt in manager.get_all_tasks():
|
||||
result.append(QueuedTaskSchema(
|
||||
id=qt.task.id,
|
||||
platform=qt.task.platform.value,
|
||||
status=qt.task.status.value,
|
||||
progress=qt.progress,
|
||||
progress_message=qt.progress_message,
|
||||
retries=qt.retries,
|
||||
created_at=qt.created_at.isoformat(),
|
||||
started_at=qt.started_at.isoformat() if qt.started_at else None,
|
||||
completed_at=qt.completed_at.isoformat() if qt.completed_at else None,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/queue/tasks/{task_id}", response_model=QueuedTaskSchema)
|
||||
async def get_queued_task(task_id: str = Path(..., description="Task ID")):
|
||||
"""Get specific queued task status."""
|
||||
manager = get_publish_manager()
|
||||
|
||||
qt = manager.get_task(task_id)
|
||||
if not qt:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
return QueuedTaskSchema(
|
||||
id=qt.task.id,
|
||||
platform=qt.task.platform.value,
|
||||
status=qt.task.status.value,
|
||||
progress=qt.progress,
|
||||
progress_message=qt.progress_message,
|
||||
retries=qt.retries,
|
||||
created_at=qt.created_at.isoformat(),
|
||||
started_at=qt.started_at.isoformat() if qt.started_at else None,
|
||||
completed_at=qt.completed_at.isoformat() if qt.completed_at else None,
|
||||
)
|
||||
Reference in New Issue
Block a user