MediaRouter / app /projects /services /editor_service.py
basyx's picture
Upload 340 files
3493993 verified
Raw
History Blame Contribute Delete
7.75 kB
from __future__ import annotations
import time
from app.core.logger import get_logger
from app.projects.editor_schemas import (
EDITOR_STATE_MAX_BYTES,
AudioClip,
EditorDocument,
EditorSaveRequest,
EditorStateResponse,
MediaClip,
)
from app.projects.errors import ProjectEditorAssetInvalidError, ProjectEditorInvalidError
from app.projects.models import ProjectEditorState
from app.projects.repositories.editor_repository import ProjectEditorRepository
from app.security.assets import CanonicalAssetNotFoundError, CanonicalAssetService
from app.security.audit import AuditService
logger = get_logger(__name__)
class ProjectEditorService:
def __init__(
self,
repository: ProjectEditorRepository,
assets: CanonicalAssetService,
audit: AuditService,
*,
state_max_bytes: int = EDITOR_STATE_MAX_BYTES,
max_tracks: int = 32,
max_clips: int = 500,
max_duration_seconds: int = 3600,
) -> None:
self.repository = repository
self.assets = assets
self.audit = audit
self.state_max_bytes = state_max_bytes
self.max_tracks = max_tracks
self.max_clips = max_clips
self.max_duration_ms = max_duration_seconds * 1000
async def get(self, *, workspace_id: str, user_id: str, project_id: str) -> EditorStateResponse:
return self._response(await self.repository.get(workspace_id, project_id, user_id=user_id))
async def save(
self,
*,
workspace_id: str,
user_id: str,
api_key_id: str,
request_id: str,
project_id: str,
payload: EditorSaveRequest,
) -> EditorStateResponse:
started = time.monotonic()
document = self._validate_document(payload.state, project_id)
await self._validate_assets(workspace_id, user_id, document)
try:
state, created = await self.repository.save(
workspace_id,
project_id,
user_id=user_id,
expected_revision=payload.expected_revision,
document=document,
)
except Exception as exc:
if getattr(exc, "code", None) == "PROJECT_EDITOR_REVISION_CONFLICT":
await self.audit.record_event(
workspace_id=workspace_id,
user_id=user_id,
api_key_id=api_key_id,
request_id=request_id,
event_type="project.editor_conflict",
entity_type="project",
entity_id=project_id,
metadata={"expected_revision": payload.expected_revision},
)
logger.info(
"project editor conflict",
extra={
"operation": "editor.conflict",
"project_id": project_id,
"workspace_id": workspace_id,
"expected_revision": payload.expected_revision,
},
)
raise
await self.audit.record_event(
workspace_id=workspace_id,
user_id=user_id,
api_key_id=api_key_id,
request_id=request_id,
event_type="project.editor_created" if created else "project.editor_updated",
entity_type="project",
entity_id=project_id,
metadata={"revision": state.revision, "schema_version": state.schema_version},
)
logger.info(
"project editor saved",
extra={
"operation": "editor.save",
"project_id": project_id,
"workspace_id": workspace_id,
"revision": state.revision,
"duration_ms": round((time.monotonic() - started) * 1000),
},
)
return self._response(state)
def _validate_document(self, document: EditorDocument, project_id: str) -> EditorDocument:
if document.project_id != project_id:
raise ProjectEditorInvalidError(
"Editor document project does not match the URL project."
)
if len(document.timeline.tracks) > self.max_tracks:
raise ProjectEditorInvalidError("Editor document contains too many tracks.")
clip_count = sum(len(track.clips) for track in document.timeline.tracks)
if clip_count > self.max_clips:
raise ProjectEditorInvalidError("Editor document contains too many clips.")
if document.duration_ms() > self.max_duration_ms:
raise ProjectEditorInvalidError(
"Editor timeline exceeds the configured duration limit."
)
try:
encoded = document.json_bytes()
except (TypeError, ValueError) as exc:
raise ProjectEditorInvalidError("Editor document contains invalid JSON.") from exc
if len(encoded) > self.state_max_bytes:
raise ProjectEditorInvalidError("Editor document exceeds the configured size limit.")
return document
async def _validate_assets(
self, workspace_id: str, user_id: str, document: EditorDocument
) -> None:
owned = {}
for asset_id in document.asset_ids():
try:
asset = await self.assets.get_owned_by_id(
workspace_id=workspace_id, user_id=user_id, asset_id=asset_id
)
if asset.project_id != document.project_id:
raise CanonicalAssetNotFoundError("Asset is not attached to this project.")
owned[asset_id] = asset
except CanonicalAssetNotFoundError as exc:
raise ProjectEditorAssetInvalidError(
"Every editor asset reference must belong to the authenticated workspace."
) from exc
for track in document.timeline.tracks:
for clip in track.clips:
if not isinstance(clip, (MediaClip, AudioClip)):
continue
asset = owned[clip.asset_id]
if isinstance(clip, AudioClip) and not asset.mime_type.startswith("audio/"):
raise ProjectEditorAssetInvalidError(
"An audio clip must reference an audio asset."
)
if isinstance(clip, MediaClip) and not asset.mime_type.startswith(
f"{clip.media_type}/"
):
raise ProjectEditorAssetInvalidError(
f"A {clip.media_type} clip must reference a {clip.media_type} asset."
)
metadata = asset.metadata_json or {}
duration_ms = metadata.get("duration_ms")
if duration_ms is None and isinstance(metadata.get("duration"), (int, float)):
duration_ms = float(metadata["duration"]) * 1000
if isinstance(duration_ms, (int, float)) and (
clip.source_start_ms + clip.source_duration_ms > duration_ms + 1
):
raise ProjectEditorAssetInvalidError(
"A clip source range exceeds its asset duration."
)
@staticmethod
def _response(state: ProjectEditorState) -> EditorStateResponse:
return EditorStateResponse(
project_id=state.project_id,
revision=state.revision,
schema_version=state.schema_version,
state=EditorDocument.model_validate(state.state_json),
created_at=state.created_at,
updated_at=state.updated_at,
updated_by=state.updated_by,
)