File size: 7,754 Bytes
3493993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
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,
        )