File size: 8,173 Bytes
070c931
 
 
 
 
 
66de9c8
070c931
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66de9c8
070c931
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
187
"""The video editor: one allowlisted proxy onto blog2video's /api/projects/{id}/... endpoints.

    <METHOD> /api/videos/{artifact_id}/p/<path>  ->  <METHOD> /api/projects/{blog2video id}/<path>

Only paths in RULES pass (anything else is 404). Before forwarding, the video must be this workspace's
(b2v_access.video_or_404), every template / voice / style id in a JSON body must be this workspace's (check_refs),
AI edits come out of the workspace's own counter (given back if
blog2video refuses). A template switch also uses one video, as it does on blog2video.

JSON bodies, multipart uploads (images, voiceovers, portraits) and binary answers (downloads, stills) pass through.
"""

import json
import re
import uuid
from dataclasses import dataclass

from fastapi import APIRouter, Depends, HTTPException, Request, Response
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile

from app.auth import Ctx, get_ctx
from app.routers.videos import b2v_ready, store, upstream
from app.services import b2v_access, video_limits, video_quota
from app.services import blog2video as b2v
from app.services.plans import effective_plan, plan_limit_error

router = APIRouter(prefix="/api/videos", tags=["videos"])

MAX_JSON_BYTES = 512 * 1024
MAX_FILE_BYTES = 50 * 1024 * 1024
S = r"\d+"  # a scene id


@dataclass(frozen=True)
class Rule:
    ai_edits: int = 0  # AI edits this call uses (per workspace, video_limits "ai_edits")
    uses_video: bool = False  # a template switch: one video from the allowance
    raw: bool = False  # binary answer (download, still)
    timeout: float = 30
    status: str | None = None  # artifact status to show once it started


RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m, p, r in [
    # Project settings, logo, assets
    ("GET", "", Rule(timeout=60)),  # the whole project: slow on a far-away database
    ("PATCH", "update-project", Rule()),
    ("PATCH", "", Rule()),
    ("POST", "logo", Rule()),
    ("DELETE", "logo", Rule()),
    ("PATCH", rf"assets/{S}/exclude", Rule()),
    ("DELETE", rf"assets/{S}", Rule()),
    ("GET", "layouts", Rule()),
    ("GET", "status", Rule()),
    # Scenes
    ("PUT", rf"scenes/{S}", Rule()),
    ("DELETE", rf"scenes/{S}", Rule()),
    ("PUT", "bulk-update-scenes", Rule()),
    ("POST", "scenes/reorder", Rule()),
    ("POST", rf"scenes/{S}/regenerate", Rule(ai_edits=1, timeout=120)),
    ("POST", "scenes/add", Rule(ai_edits=1)),
    ("GET", "scenes/add-status", Rule()),
    # Images
    ("POST", rf"scenes/{S}/image", Rule(timeout=60)),
    ("POST", rf"scenes/{S}/generate-image", Rule(ai_edits=1, timeout=120)),
    ("PATCH", rf"scenes/{S}/image-focus", Rule()),
    ("POST", "images/move", Rule()),
    ("POST", "images/swap", Rule()),
    ("POST", "images/duplicate", Rule()),
    ("POST", "images/assign-existing", Rule()),
    # Stock footage
    ("GET", "stock-footage/search", Rule()),
    ("POST", rf"scenes/{S}/stock-footage", Rule(ai_edits=1, timeout=60)),
    ("GET", "stock-footage/pending", Rule()),
    ("POST", "stock-footage/link", Rule()),
    ("POST", "stock-footage/approve", Rule(status="generating")),
    ("POST", "stock-footage/reject", Rule(status="generating")),
    # Voice, voiceovers, language, template
    ("POST", rf"scenes/{S}/voiceover", Rule(timeout=60)),
    # blog2video keeps the project's status during a voiceover delete; marking ours lets a reload resume its progress
    # (voice-change-status reports it, kind "delete"), and the next status check sets it back.
    ("POST", "delete-voiceover", Rule(status="voice_regenerating")),
    ("POST", "change-voice", Rule(status="voice_regenerating")),
    ("GET", "voice-change-status", Rule()),
    ("POST", "change-language", Rule(status="language_regenerating")),
    ("GET", "language-change-status", Rule()),
    ("POST", "change-template-regenerate-layouts", Rule(uses_video=True, status="regenerating")),
    ("GET", "template-change-status", Rule()),
    # Refresh the script
    ("POST", "regenerate-script", Rule(status="script_regenerating")),
    ("GET", "regenerate-script-status", Rule()),
    ("GET", "regenerate-script/preview", Rule()),
    ("POST", "regenerate-script/verify", Rule()),
    ("POST", "regenerate-script/regenerate", Rule()),
    # Render, download, frames
    ("POST", "render", Rule(status="rendering")),
    ("GET", "render-status", Rule()),
    ("POST", "cancel-render", Rule()),
    ("GET", "download-url", Rule()),
    ("GET", "download", Rule(raw=True, timeout=120)),
    ("GET", "render-still", Rule(raw=True, timeout=60)),
    ("POST", "render-stills", Rule(timeout=120)),
]]


def rule_for(method: str, path: str) -> Rule | None:
    for m, pattern, rule in RULES:
        if m == method and pattern.match(path):
            return rule
    return None


async def _read_body(request: Request) -> tuple[dict | None, dict | None, list | None]:
    """(json, form fields, files) from the browser's request."""
    ctype = request.headers.get("content-type", "")
    if ctype.startswith("multipart/form-data"):
        form = await request.form()
        data, files = {}, []
        for key, value in form.multi_items():
            if isinstance(value, UploadFile):
                content = await value.read(MAX_FILE_BYTES + 1)
                if len(content) > MAX_FILE_BYTES:
                    raise HTTPException(413, "That file is too large.")
                files.append((key, (value.filename or "file", content, value.content_type or
                                    "application/octet-stream")))
            else:
                data[key] = value
        return None, data, files
    raw = await request.body()
    if not raw:
        return None, None, None
    if len(raw) > MAX_JSON_BYTES:
        raise HTTPException(413, "Request too large")
    try:
        return json.loads(raw), None, None
    except ValueError:
        raise HTTPException(400, "Send JSON") from None


def _charge(ctx: Ctx, rule: Rule, body: dict | None, form: dict | None) -> int:
    """References and counters, all before blog2video is called. Returns AI edits taken."""
    plan = effective_plan(ctx.db, ctx.workspace)
    refs = body if isinstance(body, dict) else (form or {})
    with upstream():  # checking a template or voice may need blog2video's catalog
        b2v_access.check_refs(ctx, refs, plan)
    edits = rule.ai_edits
    if edits:
        video_limits.take(ctx.db, ctx.workspace, "ai_edits", edits, plan=plan)
    if rule.uses_video and not video_quota.reserve(ctx.db, ctx.workspace.id):
        if edits:
            video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits", edits)
        raise plan_limit_error(plan, "videos", "Switching the template uses a video, and you have none left.")
    return edits


@router.api_route("/{artifact_id}/p/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
async def project_proxy(artifact_id: uuid.UUID, path: str, request: Request, ctx: Ctx = Depends(get_ctx),
                        _: None = Depends(b2v_ready)):
    path = path.strip("/")
    rule = rule_for(request.method, path)
    if rule is None:
        raise HTTPException(404, "Not found")
    a, vid, _row = b2v_access.video_or_404(ctx, artifact_id)
    body, form, files = await _read_body(request)
    edits = await run_in_threadpool(_charge, ctx, rule, body, form)
    params = dict(request.query_params)

    def call():
        return b2v.project_call(request.method, vid, path, json=body, data=form, files=files or None,
                                params=params or None, timeout=rule.timeout, raw=rule.raw)

    try:
        with upstream():
            result = await run_in_threadpool(call)
    except HTTPException:
        if edits:
            video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits", edits)
        if rule.uses_video:
            video_quota.unreserve(ctx.db, ctx.workspace.id)
        raise
    if rule.status:
        store(ctx, a, status=rule.status)
    if rule.raw:
        content, ctype = result
        return Response(content, media_type=ctype, headers={"Cache-Control": "no-store"})
    return result