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, )