chore: claude final touches
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from cpv3.db.base import Base, BaseModelMixin
|
||||
|
||||
|
||||
class CaptionPreset(Base, BaseModelMixin):
|
||||
__tablename__ = "caption_presets"
|
||||
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
style_config: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
preview_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Select, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.modules.captions.models import CaptionPreset
|
||||
from cpv3.modules.captions.schemas import CaptionPresetCreate, CaptionPresetUpdate
|
||||
|
||||
|
||||
class CaptionPresetRepository:
|
||||
"""Repository for CaptionPreset database operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_all_for_user(self, user_id: uuid.UUID) -> list[CaptionPreset]:
|
||||
"""Return system presets + user's own presets."""
|
||||
stmt: Select[tuple[CaptionPreset]] = (
|
||||
select(CaptionPreset)
|
||||
.where(CaptionPreset.is_active.is_(True))
|
||||
.where(
|
||||
or_(
|
||||
CaptionPreset.is_system.is_(True),
|
||||
CaptionPreset.user_id == user_id,
|
||||
)
|
||||
)
|
||||
.order_by(CaptionPreset.is_system.desc(), CaptionPreset.created_at.desc())
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_id(self, preset_id: uuid.UUID) -> CaptionPreset | None:
|
||||
result = await self._session.execute(
|
||||
select(CaptionPreset)
|
||||
.where(CaptionPreset.id == preset_id)
|
||||
.where(CaptionPreset.is_active.is_(True))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(
|
||||
self, *, user_id: uuid.UUID | None, data: CaptionPresetCreate
|
||||
) -> CaptionPreset:
|
||||
preset = CaptionPreset(
|
||||
user_id=user_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
is_system=user_id is None,
|
||||
style_config=data.style_config.model_dump(mode="json"),
|
||||
)
|
||||
self._session.add(preset)
|
||||
await self._session.commit()
|
||||
await self._session.refresh(preset)
|
||||
return preset
|
||||
|
||||
async def update(self, preset: CaptionPreset, data: CaptionPresetUpdate) -> CaptionPreset:
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
if value is not None:
|
||||
if key == "style_config":
|
||||
setattr(preset, key, value.model_dump(mode="json"))
|
||||
else:
|
||||
setattr(preset, key, value)
|
||||
await self._session.commit()
|
||||
await self._session.refresh(preset)
|
||||
return preset
|
||||
|
||||
async def deactivate(self, preset: CaptionPreset) -> None:
|
||||
preset.is_active = False
|
||||
await self._session.commit()
|
||||
@@ -1,15 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.db.session import get_db
|
||||
from cpv3.infrastructure.auth import get_current_user
|
||||
from cpv3.modules.captions.schemas import CaptionsRequest, CaptionsResponse
|
||||
from cpv3.modules.captions.service import generate_captions
|
||||
from cpv3.modules.captions.schemas import (
|
||||
CaptionPresetCreate,
|
||||
CaptionPresetRead,
|
||||
CaptionPresetUpdate,
|
||||
CaptionsRequest,
|
||||
CaptionsResponse,
|
||||
)
|
||||
from cpv3.modules.captions.service import CaptionPresetService, generate_captions
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
router = APIRouter(prefix="/api/captions", tags=["Captions"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy direct render endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/get_video/", response_model=CaptionsResponse)
|
||||
async def get_video(
|
||||
body: CaptionsRequest, current_user: User = Depends(get_current_user)
|
||||
@@ -21,3 +36,87 @@ async def get_video(
|
||||
transcription=body.transcription,
|
||||
)
|
||||
return CaptionsResponse(result=result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preset CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/presets/", response_model=list[CaptionPresetRead])
|
||||
async def list_presets(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[CaptionPresetRead]:
|
||||
"""List system presets + user's own presets."""
|
||||
service = CaptionPresetService(db)
|
||||
presets = await service.list_presets(user_id=current_user.id)
|
||||
return [CaptionPresetRead.model_validate(p, from_attributes=True) for p in presets]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/presets/",
|
||||
response_model=CaptionPresetRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_preset(
|
||||
body: CaptionPresetCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CaptionPresetRead:
|
||||
"""Create a user preset."""
|
||||
service = CaptionPresetService(db)
|
||||
preset = await service.create_preset(user_id=current_user.id, data=body)
|
||||
return CaptionPresetRead.model_validate(preset, from_attributes=True)
|
||||
|
||||
|
||||
@router.get("/presets/{preset_id}/", response_model=CaptionPresetRead)
|
||||
async def get_preset(
|
||||
preset_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CaptionPresetRead:
|
||||
"""Get a single preset."""
|
||||
_ = current_user
|
||||
service = CaptionPresetService(db)
|
||||
try:
|
||||
preset = await service.get_preset(preset_id=preset_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
return CaptionPresetRead.model_validate(preset, from_attributes=True)
|
||||
|
||||
|
||||
@router.patch("/presets/{preset_id}/", response_model=CaptionPresetRead)
|
||||
async def update_preset(
|
||||
preset_id: uuid.UUID,
|
||||
body: CaptionPresetUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CaptionPresetRead:
|
||||
"""Update a user preset (cannot edit system or others' presets)."""
|
||||
service = CaptionPresetService(db)
|
||||
try:
|
||||
preset = await service.update_preset(
|
||||
preset_id=preset_id, user_id=current_user.id, data=body
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
return CaptionPresetRead.model_validate(preset, from_attributes=True)
|
||||
|
||||
|
||||
@router.delete("/presets/{preset_id}/", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_preset(
|
||||
preset_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Delete a user preset (cannot delete system or others' presets)."""
|
||||
service = CaptionPresetService(db)
|
||||
try:
|
||||
await service.delete_preset(preset_id=preset_id, user_id=current_user.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
|
||||
@@ -1,9 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from cpv3.common.schemas import Schema
|
||||
from cpv3.modules.transcription.schemas import Document
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caption style config sub-schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CaptionTextStyle(Schema):
|
||||
font_family: str = "Lobster"
|
||||
font_size: int = 40
|
||||
font_weight: int = 400
|
||||
text_color: str = "#FFFFFF"
|
||||
highlight_color: str = "#FFCC00"
|
||||
text_shadow: str | None = "2px 2px 4px rgba(0,0,0,0.5)"
|
||||
text_stroke_width: float = 0
|
||||
text_stroke_color: str = "#000000"
|
||||
|
||||
|
||||
class CaptionLayoutStyle(Schema):
|
||||
vertical_position: Literal["top", "center", "bottom"] = "bottom"
|
||||
horizontal_alignment: Literal["left", "center", "right"] = "center"
|
||||
padding_px: int = 20
|
||||
max_width_pct: int = 90
|
||||
lines_per_screen: int = 2
|
||||
|
||||
|
||||
class CaptionAnimationStyle(Schema):
|
||||
highlight_style: Literal["color", "scale", "underline", "color_scale"] = "color"
|
||||
highlight_scale: float = 1.1
|
||||
segment_transition: Literal["fade", "slide", "none"] = "fade"
|
||||
fade_duration_frames: int = 3
|
||||
animation_speed: float = 1.0
|
||||
|
||||
|
||||
class CaptionBackgroundStyle(Schema):
|
||||
bg_color: str = "rgba(0,0,0,0.6)"
|
||||
bg_blur_px: int = 0
|
||||
bg_glow_color: str | None = None
|
||||
bg_border_radius_px: int = 15
|
||||
bg_padding_px: int = 20
|
||||
|
||||
|
||||
class CaptionStyleConfig(Schema):
|
||||
text: CaptionTextStyle = Field(default_factory=CaptionTextStyle)
|
||||
layout: CaptionLayoutStyle = Field(default_factory=CaptionLayoutStyle)
|
||||
animation: CaptionAnimationStyle = Field(default_factory=CaptionAnimationStyle)
|
||||
background: CaptionBackgroundStyle = Field(default_factory=CaptionBackgroundStyle)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preset CRUD schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CaptionPresetCreate(Schema):
|
||||
name: str = Field(..., max_length=128)
|
||||
description: str | None = None
|
||||
style_config: CaptionStyleConfig
|
||||
|
||||
|
||||
class CaptionPresetUpdate(Schema):
|
||||
name: str | None = Field(default=None, max_length=128)
|
||||
description: str | None = None
|
||||
style_config: CaptionStyleConfig | None = None
|
||||
|
||||
|
||||
class CaptionPresetRead(Schema):
|
||||
id: UUID
|
||||
user_id: UUID | None
|
||||
name: str
|
||||
description: str | None
|
||||
is_system: bool
|
||||
style_config: CaptionStyleConfig
|
||||
preview_url: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Existing request/response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CaptionsRequest(Schema):
|
||||
folder: str
|
||||
video_s3_path: str
|
||||
|
||||
@@ -1,31 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.infrastructure.settings import get_settings
|
||||
from cpv3.modules.captions.models import CaptionPreset
|
||||
from cpv3.modules.captions.repository import CaptionPresetRepository
|
||||
from cpv3.modules.captions.schemas import (
|
||||
CaptionPresetCreate,
|
||||
CaptionPresetUpdate,
|
||||
CaptionStyleConfig,
|
||||
)
|
||||
from cpv3.modules.transcription.schemas import Document
|
||||
|
||||
ERROR_PRESET_NOT_FOUND = "Пресет субтитров не найден"
|
||||
ERROR_PRESET_FORBIDDEN = "Нельзя изменять чужой или системный пресет"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caption rendering (calls Remotion service)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def generate_captions(
|
||||
*, video_s3_path: str, folder: str, transcription: Document
|
||||
*,
|
||||
video_s3_path: str,
|
||||
folder: str,
|
||||
transcription: Document,
|
||||
style_config: dict | None = None,
|
||||
callback_url: str | None = None,
|
||||
render_id: str | None = None,
|
||||
) -> str:
|
||||
"""Generate captions for a video using the Remotion service."""
|
||||
"""Generate captions for a video using the Remotion service.
|
||||
|
||||
Returns render_id (async mode with callback_url) or S3 output path (sync fallback).
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
payload = {
|
||||
payload: dict = {
|
||||
"folder": folder,
|
||||
"videoSrc": video_s3_path,
|
||||
"transcription": transcription.model_dump(),
|
||||
}
|
||||
if style_config is not None:
|
||||
payload["styleConfig"] = style_config
|
||||
if callback_url is not None:
|
||||
payload["callbackUrl"] = callback_url
|
||||
if render_id is not None:
|
||||
payload["renderId"] = render_id
|
||||
|
||||
async with httpx.AsyncClient(timeout=300) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.remotion_service_url}/api/render", json=payload
|
||||
)
|
||||
timeout = 30.0 if callback_url else 300.0
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(f"{settings.remotion_service_url}/api/render", json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if not isinstance(data, dict) or "output" not in data:
|
||||
raise RuntimeError("Unexpected response from remotion service")
|
||||
# Async mode: Remotion returns { renderId, status: "queued" }
|
||||
if callback_url and "renderId" in data:
|
||||
return str(data["renderId"])
|
||||
|
||||
return str(data["output"])
|
||||
# Sync fallback: Remotion returns { output: "s3/path" }
|
||||
if isinstance(data, dict) and "output" in data:
|
||||
return str(data["output"])
|
||||
|
||||
raise RuntimeError("Unexpected response from remotion service")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preset service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CaptionPresetService:
|
||||
"""Business logic for caption preset CRUD with ownership checks."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._repo = CaptionPresetRepository(session)
|
||||
|
||||
async def list_presets(self, *, user_id: uuid.UUID) -> list[CaptionPreset]:
|
||||
return await self._repo.list_all_for_user(user_id)
|
||||
|
||||
async def get_preset(self, *, preset_id: uuid.UUID) -> CaptionPreset:
|
||||
preset = await self._repo.get_by_id(preset_id)
|
||||
if preset is None:
|
||||
raise ValueError(ERROR_PRESET_NOT_FOUND)
|
||||
return preset
|
||||
|
||||
async def create_preset(
|
||||
self, *, user_id: uuid.UUID, data: CaptionPresetCreate
|
||||
) -> CaptionPreset:
|
||||
return await self._repo.create(user_id=user_id, data=data)
|
||||
|
||||
async def update_preset(
|
||||
self,
|
||||
*,
|
||||
preset_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
data: CaptionPresetUpdate,
|
||||
) -> CaptionPreset:
|
||||
preset = await self.get_preset(preset_id=preset_id)
|
||||
if preset.is_system or preset.user_id != user_id:
|
||||
raise PermissionError(ERROR_PRESET_FORBIDDEN)
|
||||
return await self._repo.update(preset, data)
|
||||
|
||||
async def delete_preset(self, *, preset_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
preset = await self.get_preset(preset_id=preset_id)
|
||||
if preset.is_system or preset.user_id != user_id:
|
||||
raise PermissionError(ERROR_PRESET_FORBIDDEN)
|
||||
await self._repo.deactivate(preset)
|
||||
|
||||
async def resolve_style_config(
|
||||
self,
|
||||
*,
|
||||
preset_id: uuid.UUID | None = None,
|
||||
inline_config: dict | None = None,
|
||||
) -> dict | None:
|
||||
"""Resolve a style config from preset_id or inline override."""
|
||||
if inline_config is not None:
|
||||
# Validate by parsing, then return as dict
|
||||
cfg = CaptionStyleConfig.model_validate(inline_config)
|
||||
return cfg.model_dump(mode="json")
|
||||
if preset_id is not None:
|
||||
preset = await self.get_preset(preset_id=preset_id)
|
||||
return preset.style_config
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user