Spaces:
Sleeping
Sleeping
| 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." | |
| ) | |
| 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, | |
| ) | |