Voice / backend /post_projects.py
Thang6822
Deploy OmniVoice Docker Space
342571f
Raw
History Blame Contribute Delete
12.5 kB
from __future__ import annotations
import time
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from capabilities import CapabilityService
from data_paths import DataPaths, make_id, read_json_file, write_json_file
from media_composer import build_slideshow_video
class TimelineItem(BaseModel):
id: str
title: str
source_video_project_id: str | None = None
asset_filename: str | None = None
duration_seconds: float = 3.0
class PostPreview(BaseModel):
id: str
filename: str
created_at: int
duration_seconds: float
class PostExport(BaseModel):
id: str
filename: str
created_at: int
duration_seconds: float
class PostProject(BaseModel):
id: str
name: str
aspect_ratio: Literal["9:16", "16:9"] = "9:16"
width: int = 1080
height: int = 1920
source_video_project_id: str | None = None
source_audio_project_id: str | None = None
audio_mixdown_filename: str | None = None
timeline: list[TimelineItem]
previews: list[PostPreview] = Field(default_factory=list)
exports: list[PostExport] = Field(default_factory=list)
created_at: int
updated_at: int
class PostProjectCreateRequest(BaseModel):
name: str = "Post Production Project"
source_video_project_id: str | None = None
source_audio_project_id: str | None = None
class PostProjectUpdateRequest(BaseModel):
name: str | None = None
aspect_ratio: Literal["9:16", "16:9"] | None = None
width: int | None = None
height: int | None = None
audio_mixdown_filename: str | None = None
timeline: list[TimelineItem] | None = None
class PostRenderRequest(BaseModel):
fps: int = 24
class PostProjectSummary(BaseModel):
id: str
name: str
timeline_count: int
updated_at: int
def create_post_router(
capabilities: CapabilityService,
data_paths: DataPaths,
) -> APIRouter:
router = APIRouter(prefix="/api/post", tags=["post"])
store = PostProjectStore(data_paths)
@router.get("/projects")
async def list_projects() -> list[dict[str, object]]:
capabilities.require_feature("post_production")
return [summary.model_dump() for summary in store.list_projects()]
@router.post("/projects")
async def create_project(req: PostProjectCreateRequest) -> dict[str, object]:
capabilities.require_feature("post_production")
return store.create_project(req).model_dump()
@router.get("/projects/{project_id}")
async def get_project(project_id: str) -> dict[str, object]:
capabilities.require_feature("post_production")
return store.get_project(project_id).model_dump()
@router.patch("/projects/{project_id}")
async def update_project(
project_id: str,
req: PostProjectUpdateRequest,
) -> dict[str, object]:
capabilities.require_feature("post_production")
project = store.get_project(project_id)
payload = project.model_dump()
if req.name is not None:
payload["name"] = req.name
if req.aspect_ratio is not None:
payload["aspect_ratio"] = req.aspect_ratio
if req.width is not None:
payload["width"] = req.width
if req.height is not None:
payload["height"] = req.height
if req.audio_mixdown_filename is not None:
payload["audio_mixdown_filename"] = req.audio_mixdown_filename
if req.timeline is not None:
payload["timeline"] = [item.model_dump() for item in req.timeline]
updated = PostProject.model_validate(payload)
store.save_project(updated)
return updated.model_dump()
@router.post("/projects/{project_id}/preview")
async def build_preview(
project_id: str,
req: PostRenderRequest,
) -> dict[str, object]:
capabilities.require_feature("post_production")
preview = store.build_preview(project_id, req.fps)
return preview.model_dump()
@router.post("/projects/{project_id}/export")
async def export_project(
project_id: str,
req: PostRenderRequest,
) -> dict[str, object]:
capabilities.require_feature("post_production")
capabilities.require_feature("video_export")
export = store.export_project(project_id, req.fps)
return export.model_dump()
@router.get("/projects/{project_id}/files/{category}/{filename}")
async def get_project_file(
project_id: str,
category: Literal["previews", "exports"],
filename: str,
) -> FileResponse:
capabilities.require_feature("post_production")
file_path = store.resolve_project_file(project_id, category, filename)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Post-production file not found.")
return FileResponse(file_path, filename=file_path.name)
return router
class PostProjectStore:
def __init__(self, data_paths: DataPaths) -> None:
self.data_paths = data_paths
def list_projects(self) -> list[PostProjectSummary]:
summaries: list[PostProjectSummary] = []
for manifest_path in sorted(self.data_paths.post_root.glob("*/project.json")):
project = PostProject.model_validate(read_json_file(manifest_path, {}))
summaries.append(
PostProjectSummary(
id=project.id,
name=project.name,
timeline_count=len(project.timeline),
updated_at=project.updated_at,
)
)
summaries.sort(key=lambda item: item.updated_at, reverse=True)
return summaries
def create_project(self, req: PostProjectCreateRequest) -> PostProject:
now = int(time.time())
timeline = self._build_timeline_from_video(req.source_video_project_id)
audio_mixdown_filename = self._latest_audio_mixdown(req.source_audio_project_id)
project = PostProject(
id=make_id("post"),
name=req.name,
source_video_project_id=req.source_video_project_id,
source_audio_project_id=req.source_audio_project_id,
audio_mixdown_filename=audio_mixdown_filename,
timeline=timeline,
created_at=now,
updated_at=now,
)
self.save_project(project)
return project
def get_project(self, project_id: str) -> PostProject:
manifest_path = self._project_manifest_path(project_id)
payload = read_json_file(manifest_path, None)
if payload is None:
raise HTTPException(status_code=404, detail="Post-production project not found.")
return PostProject.model_validate(payload)
def save_project(self, project: PostProject) -> None:
project.updated_at = int(time.time())
root = self._project_root(project.id)
(root / "previews").mkdir(parents=True, exist_ok=True)
(root / "exports").mkdir(parents=True, exist_ok=True)
write_json_file(self._project_manifest_path(project.id), project.model_dump())
def build_preview(self, project_id: str, fps: int) -> PostPreview:
project = self.get_project(project_id)
preview = PostPreview(
id=make_id("preview"),
filename=f"{make_id('preview')}.mp4",
created_at=int(time.time()),
duration_seconds=round(sum(item.duration_seconds for item in project.timeline), 2),
)
preview_path = self.resolve_project_file(project_id, "previews", preview.filename)
build_slideshow_video(
items=[(self._resolve_timeline_asset(item), item.duration_seconds) for item in project.timeline],
output_path=preview_path,
width=project.width,
height=project.height,
fps=fps,
audio_path=None,
)
project.previews.insert(0, preview)
self.save_project(project)
return preview
def export_project(self, project_id: str, fps: int) -> PostExport:
project = self.get_project(project_id)
export = PostExport(
id=make_id("export"),
filename=f"{make_id('master')}.mp4",
created_at=int(time.time()),
duration_seconds=round(sum(item.duration_seconds for item in project.timeline), 2),
)
export_path = self.resolve_project_file(project_id, "exports", export.filename)
build_slideshow_video(
items=[(self._resolve_timeline_asset(item), item.duration_seconds) for item in project.timeline],
output_path=export_path,
width=project.width,
height=project.height,
fps=fps,
audio_path=self._resolve_audio_mixdown(project),
)
project.exports.insert(0, export)
self.save_project(project)
return export
def resolve_project_file(
self,
project_id: str,
category: Literal["previews", "exports"],
filename: str,
) -> Path:
return self._project_root(project_id) / category / Path(filename).name
def _project_root(self, project_id: str) -> Path:
return self.data_paths.post_root / project_id
def _project_manifest_path(self, project_id: str) -> Path:
return self._project_root(project_id) / "project.json"
def _build_timeline_from_video(self, video_project_id: str | None) -> list[TimelineItem]:
if not video_project_id:
return [
TimelineItem(
id=make_id("timeline"),
title="Scene 1",
duration_seconds=3.0,
)
]
manifest = read_json_file(self.data_paths.video_root / video_project_id / "project.json", None)
if manifest is None:
return [
TimelineItem(
id=make_id("timeline"),
title="Scene 1",
duration_seconds=3.0,
)
]
timeline: list[TimelineItem] = []
for scene in manifest.get("scenes", []):
timeline.append(
TimelineItem(
id=make_id("timeline"),
title=scene.get("title", "Scene"),
source_video_project_id=video_project_id,
asset_filename=self._resolve_video_asset_filename(manifest, scene.get("asset_id")),
duration_seconds=float(scene.get("duration_seconds", 3.0)),
)
)
return timeline or [
TimelineItem(
id=make_id("timeline"),
title="Scene 1",
duration_seconds=3.0,
)
]
def _resolve_video_asset_filename(self, manifest: dict[str, object], asset_id: str | None) -> str | None:
if not asset_id:
return None
for asset in manifest.get("assets", []):
if asset.get("id") == asset_id:
return asset.get("filename")
return None
def _latest_audio_mixdown(self, audio_project_id: str | None) -> str | None:
if not audio_project_id:
return None
manifest = read_json_file(self.data_paths.audio_root / audio_project_id / "project.json", None)
if manifest is None:
return None
mixdowns = manifest.get("mixdowns", [])
if not mixdowns:
return None
return mixdowns[0].get("filename")
def _resolve_timeline_asset(self, item: TimelineItem) -> Path | None:
if not item.source_video_project_id or not item.asset_filename:
return None
asset_path = self.data_paths.video_root / item.source_video_project_id / "assets" / item.asset_filename
if asset_path.exists():
return asset_path
return None
def _resolve_audio_mixdown(self, project: PostProject) -> Path | None:
if not project.source_audio_project_id or not project.audio_mixdown_filename:
return None
audio_path = (
self.data_paths.audio_root
/ project.source_audio_project_id
/ "mixdowns"
/ project.audio_mixdown_filename
)
if audio_path.exists():
return audio_path
return None