chore: claude final touches

This commit is contained in:
Daniil
2026-03-17 18:11:23 +03:00
parent 4b90925c2a
commit 0299949553
21 changed files with 1915 additions and 101 deletions
+25
View File
@@ -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)
+70
View File
@@ -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()
+102 -3
View File
@@ -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))
+87
View File
@@ -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
+108 -10
View File
@@ -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