From 50a99d4c955ab97c538dd1b5ec677b63f822aff3 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 27 Jul 2026 21:54:25 -0400 Subject: [PATCH 1/2] perf(video): stream uploads into a single on-disk copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declaring `file: UploadFile` makes Starlette parse the multipart body into its own spooled temp file before the route runs; the route then copied that into a named temp file it could hand to ffmpeg and `videos.create`. Every in-flight upload therefore held TWO full-size copies in temp storage (up to 2 x MAX_UPLOAD_SIZE x MAX_CONCURRENT_VIDEO_UPLOADS = 4 GB) for the whole probe/thumbnail/create phase. #9163 only shrank the overlap window by closing the spool right after the copy loop; the spool's own path can't be reused, because once rolled over it is an unlinked anonymous file with no path. The route now parses the body itself with python_multipart's streaming parser (already a FastAPI dependency), writing the `file` part directly into the one temp file and buffering only the small `metadata` field. Peak temp usage per upload is halved. Two behavioral improvements fall out of streaming the body: - The filename/MIME gate fires from the part headers, so an unsupported file is rejected before any of its bytes reach the disk instead of after the whole body has been spooled. - MAX_UPLOAD_SIZE is enforced as the bytes arrive rather than after. Parsing runs in the thread pool (it writes to disk), and the request schema is pinned with `openapi_extra` so the documented multipart contract is byte-for- byte what the `file` + `metadata` parameters generated — the only OpenAPI change is that the body schema is now inline rather than a `Body_upload_video` component, which nothing references. Deferred non-blocker from PR #9163. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/videos.py | 233 ++++++++++++++---- .../frontend/web/src/services/api/schema.ts | 26 +- tests/app/api/test_video_upload_limits.py | 215 ++++++++++++++-- 3 files changed, 387 insertions(+), 87 deletions(-) diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py index c535aa3b931..fe6b75b0f72 100644 --- a/invokeai/app/api/routers/videos.py +++ b/invokeai/app/api/routers/videos.py @@ -6,12 +6,13 @@ from pathlib import Path from typing import Annotated, BinaryIO, Optional -from fastapi import Body, HTTPException, Query, Request, Response, UploadFile +from fastapi import Body, HTTPException, Query, Request, Response from fastapi import Path as PathParam from fastapi.responses import StreamingResponse from fastapi.routing import APIRouter from PIL import Image as PILImage from pydantic import BaseModel, Field, StringConstraints, ValidationError +from python_multipart.multipart import MultipartParser, parse_options_header from starlette.concurrency import run_in_threadpool from invokeai.app.api.auth_dependencies import CurrentMediaUserOrDefault, CurrentUserOrDefault @@ -48,15 +49,17 @@ # Per-chunk size for HTTP Range responses (1 MB) RANGE_CHUNK_SIZE = 1024 * 1024 -# Upload streaming chunk size (1 MB) and a coarse per-upload size cap. The cap is generous +# Coarse per-upload size cap, enforced against the file part as it streams in. Generous # because Wan-generated MP4s for long sequences can run into the hundreds of megabytes; -# the goal is to prevent a single client from exhausting RAM, not to be a content policy. -UPLOAD_CHUNK_SIZE = 1024 * 1024 +# the goal is to prevent a single client from exhausting RAM/disk, not to be a content policy. MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 # 1 GB # Pre-parse ingress cap enforced by VideoUploadLimitASGIMiddleware, applied to the whole -# request body *before* the multipart parser spools it to temp storage. Slightly larger -# than MAX_UPLOAD_SIZE to allow for multipart framing and the metadata form field. +# request body *before* the upload route parses it. Slightly larger than MAX_UPLOAD_SIZE +# to allow for multipart framing and the metadata form field. MAX_UPLOAD_REQUEST_SIZE = MAX_UPLOAD_SIZE + 10 * 1024 * 1024 +# The `metadata` form field is a stringified JSON dict; it is buffered in memory while the +# body streams, so it gets its own (generous) cap. +MAX_UPLOAD_METADATA_SIZE = 1024 * 1024 # Global bound on concurrent video uploads — each in-flight upload can hold up to two # copies of the file in temp storage (the multipart spool + the route's own tmp file). MAX_CONCURRENT_VIDEO_UPLOADS = 2 @@ -163,14 +166,147 @@ def _assert_video_read_access(video_name: str, current_user: CurrentUserOrDefaul raise HTTPException(status_code=403, detail="Not authorized to access this video") -def _is_accepted_video_upload(file: UploadFile) -> bool: - if file.content_type and file.content_type.startswith(ACCEPTED_VIDEO_MIME_PREFIXES): +def _is_accepted_video_upload(filename: Optional[str], content_type: Optional[str]) -> bool: + if content_type and content_type.startswith(ACCEPTED_VIDEO_MIME_PREFIXES): return True - if file.filename: - return file.filename.lower().endswith(ACCEPTED_VIDEO_EXTENSIONS) + if filename: + return filename.lower().endswith(ACCEPTED_VIDEO_EXTENSIONS) return False +class _VideoUploadStreamParser: + """Parses the multipart upload body, writing the file part straight to `destination`. + + Declaring `file: UploadFile` on the route makes Starlette parse the body into its own + spooled temp file first, so the route's copy to a named temp file was a SECOND + full-size copy: every in-flight upload occupied up to 2 x MAX_UPLOAD_SIZE of temp + storage (x MAX_CONCURRENT_VIDEO_UPLOADS) for the whole probe/thumbnail/create phase. + The spool's path cannot be reused instead — once rolled over it is an unlinked + anonymous file, and both ffmpeg and videos.create need a real path. + + Parsing the stream ourselves keeps exactly one copy on disk. It also lets the + file-type and size checks fire while the body is still arriving, rather than after the + whole thing has been written somewhere. + + Callbacks run inside `MultipartParser.write`, which the route calls in a worker thread + — the disk writes must not happen on the event loop. + """ + + def __init__(self, destination: BinaryIO) -> None: + self._destination = destination + self._header_field = bytearray() + self._header_value = bytearray() + self._headers: dict[bytes, bytes] = {} + self._part_name: Optional[bytes] = None + self._metadata_chunks: list[bytes] = [] + self._metadata_size = 0 + self.filename: Optional[str] = None + self.content_type: Optional[str] = None + self.metadata: Optional[str] = None + self.file_size = 0 + self.saw_file_part = False + + @property + def callbacks(self) -> dict[str, object]: + return { + "on_part_begin": self._on_part_begin, + "on_part_data": self._on_part_data, + "on_part_end": self._on_part_end, + "on_header_field": self._on_header_field, + "on_header_value": self._on_header_value, + "on_header_end": self._on_header_end, + "on_headers_finished": self._on_headers_finished, + } + + def _on_part_begin(self) -> None: + self._headers = {} + self._header_field = bytearray() + self._header_value = bytearray() + self._part_name = None + self._metadata_chunks = [] + self._metadata_size = 0 + + def _on_header_field(self, data: bytes, start: int, end: int) -> None: + self._header_field.extend(data[start:end]) + + def _on_header_value(self, data: bytes, start: int, end: int) -> None: + self._header_value.extend(data[start:end]) + + def _on_header_end(self) -> None: + self._headers[bytes(self._header_field).lower()] = bytes(self._header_value) + self._header_field = bytearray() + self._header_value = bytearray() + + def _on_headers_finished(self) -> None: + _, options = parse_options_header(self._headers.get(b"content-disposition", b"")) + self._part_name = options.get(b"name") + if self._part_name != b"file": + return + if self.saw_file_part: + raise HTTPException(status_code=422, detail="Expected exactly one video file") + self.saw_file_part = True + filename = options.get(b"filename") + self.filename = filename.decode("utf-8", errors="replace") if filename is not None else None + content_type = self._headers.get(b"content-type") + self.content_type = content_type.decode("latin-1") if content_type is not None else None + # Reject the wrong kind of file before any of its bytes reach the disk. + if not _is_accepted_video_upload(self.filename, self.content_type): + raise HTTPException(status_code=415, detail="Not a supported video file") + + def _on_part_data(self, data: bytes, start: int, end: int) -> None: + chunk = data[start:end] + if self._part_name == b"file": + self.file_size += len(chunk) + if self.file_size > MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=413, + detail=f"Video upload exceeds maximum size ({MAX_UPLOAD_SIZE} bytes)", + ) + self._destination.write(chunk) + elif self._part_name == b"metadata": + self._metadata_size += len(chunk) + if self._metadata_size > MAX_UPLOAD_METADATA_SIZE: + raise HTTPException( + status_code=413, + detail=f"Video metadata exceeds maximum size ({MAX_UPLOAD_METADATA_SIZE} bytes)", + ) + self._metadata_chunks.append(chunk) + # Any other field is dropped rather than buffered: an unknown part must not be a + # way to make the server hold arbitrary bytes in memory. + + def _on_part_end(self) -> None: + if self._part_name == b"metadata": + try: + self.metadata = b"".join(self._metadata_chunks).decode("utf-8") + except UnicodeDecodeError as error: + raise HTTPException(status_code=422, detail="Metadata must be UTF-8 encoded") from error + self._part_name = None + self._metadata_chunks = [] + self._metadata_size = 0 + + +async def _stream_video_upload(request: Request, destination: BinaryIO) -> _VideoUploadStreamParser: + """Streams the request body through the multipart parser into `destination`.""" + media_type, options = parse_options_header(request.headers.get("content-type", "")) + boundary = options.get(b"boundary") + if media_type != b"multipart/form-data" or boundary is None: + raise HTTPException(status_code=422, detail="Expected a multipart/form-data video upload") + + parser_state = _VideoUploadStreamParser(destination) + # max_size is the pre-parse ingress cap the middleware already enforces; repeating it + # here bounds the parser itself for any path that reaches it directly. + parser = MultipartParser(boundary, parser_state.callbacks, max_size=MAX_UPLOAD_REQUEST_SIZE) + async for chunk in request.stream(): + # Parsing writes to disk, so it belongs in the thread pool alongside the rest of + # the blocking upload work. + await run_in_threadpool(parser.write, chunk) + await run_in_threadpool(parser.finalize) + + if not parser_state.saw_file_part: + raise HTTPException(status_code=422, detail="Expected a video file in the upload") + return parser_state + + def _is_mp4_file(path: Path) -> bool: try: with open(path, "rb") as video_file: @@ -230,29 +366,43 @@ def _probe_decodable_video(path: Path) -> tuple[tuple[int, int, float, Optional[ }, status_code=201, response_model=VideoDTO, + # The body is parsed by hand (see _stream_video_upload) so the file lands in exactly + # one temp file, which means FastAPI cannot infer the request schema from the + # signature. This spells out the same multipart body the `file` + `metadata` + # parameters used to generate, so the documented contract is unchanged. + openapi_extra={ + "requestBody": { + "required": True, + "content": { + "multipart/form-data": { + "schema": { + "title": "Body_upload_video", + "type": "object", + "properties": { + "file": {"title": "File", "type": "string", "format": "binary"}, + "metadata": { + "title": "Metadata", + "description": "The metadata to associate with the video, must be a stringified JSON dict", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + }, + "required": ["file"], + } + } + }, + } + }, ) async def upload_video( current_user: CurrentUserOrDefault, - file: UploadFile, request: Request, response: Response, video_category: ImageCategory = Query(description="The category of the video"), is_intermediate: bool = Query(description="Whether this is an intermediate video"), board_id: Optional[str] = Query(default=None, description="The board to add this video to, if any"), session_id: Optional[str] = Query(default=None, description="The session ID associated with this upload, if any"), - metadata: Optional[str] = Body( - default=None, - description="The metadata to associate with the video, must be a stringified JSON dict", - embed=True, - ), ) -> VideoDTO: """Uploads a video for the current user.""" - if metadata is not None: - try: - MetadataFieldValidator.validate_json(metadata) - except ValidationError as e: - raise HTTPException(status_code=422, detail="Metadata must be a JSON object") from e - # Check board access for uploads to a specific board. if board_id is not None: from invokeai.app.services.board_records.board_records_common import BoardVisibility @@ -268,35 +418,24 @@ async def upload_video( ): raise HTTPException(status_code=403, detail="Not authorized to upload to this board") - if not _is_accepted_video_upload(file): - raise HTTPException(status_code=415, detail="Not a supported video file") - - # Stream the upload to a tmp file so we can probe and then hand its path to the service. - # Reading the full body into memory first risked exhausting RAM on multi-GB uploads; - # chunk-stream instead and enforce a hard size cap. Filesystem writes, container - # validation, ffmpeg probing, and thumbnail extraction are all blocking — run them in - # the thread pool so a slow (or hostile) file can't stall the event loop and every - # other API request with it. + # Stream the upload straight into a tmp file so we can probe it and then hand its path + # to the service. Reading the full body into memory first risked exhausting RAM on + # multi-GB uploads; the parser streams it instead and enforces a hard size cap as the + # bytes arrive. Filesystem writes, container validation, ffmpeg probing, and thumbnail + # extraction are all blocking — run them in the thread pool so a slow (or hostile) file + # can't stall the event loop and every other API request with it. tmp = tempfile.NamedTemporaryFile(prefix="invokeai_upload_", suffix=".mp4", delete=False) tmp_path = Path(tmp.name) try: - total = 0 - while chunk := await file.read(UPLOAD_CHUNK_SIZE): - total += len(chunk) - if total > MAX_UPLOAD_SIZE: - tmp.close() - raise HTTPException( - status_code=413, - detail=f"Video upload exceeds maximum size ({MAX_UPLOAD_SIZE} bytes)", - ) - await run_in_threadpool(tmp.write, chunk) + upload = await _stream_video_upload(request, tmp) tmp.close() - # Release the multipart spool now that the body is copied: each in-flight - # upload otherwise holds TWO on-disk copies (Starlette's spool + our tmp file) - # through the probe/thumbnail/create phase — up to 2 x MAX_UPLOAD_SIZE x - # MAX_CONCURRENT_VIDEO_UPLOADS of temp disk. Closing shrinks the double-copy - # window to the copy loop itself. - await file.close() + + metadata = upload.metadata + if metadata is not None: + try: + MetadataFieldValidator.validate_json(metadata) + except ValidationError as e: + raise HTTPException(status_code=422, detail="Metadata must be a JSON object") from e if not await run_in_threadpool(_is_mp4_file, tmp_path): raise HTTPException(status_code=415, detail="Not an MP4 video file") diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 3f2ef2a3a0a..4ca67b056c5 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -4961,19 +4961,6 @@ export type components = { */ metadata?: string | null; }; - /** Body_upload_video */ - Body_upload_video: { - /** - * File - * Format: binary - */ - file: Blob; - /** - * Metadata - * @description The metadata to associate with the video, must be a stringified JSON dict - */ - metadata?: string | null; - }; /** * Boolean Collection Primitive * @description A collection of boolean primitive values @@ -40320,7 +40307,18 @@ export interface operations { }; requestBody: { content: { - "multipart/form-data": components["schemas"]["Body_upload_video"]; + "multipart/form-data": { + /** + * File + * Format: binary + */ + file: Blob; + /** + * Metadata + * @description The metadata to associate with the video, must be a stringified JSON dict + */ + metadata?: string | null; + }; }; }; responses: { diff --git a/tests/app/api/test_video_upload_limits.py b/tests/app/api/test_video_upload_limits.py index 0d7d972e3bd..d8f2b0de6a0 100644 --- a/tests/app/api/test_video_upload_limits.py +++ b/tests/app/api/test_video_upload_limits.py @@ -1,8 +1,10 @@ -"""Tests for VideoUploadLimitASGIMiddleware (PR #9163 review fix). +"""Tests for VideoUploadLimitASGIMiddleware and the upload route's body handling. -The upload route's MAX_UPLOAD_SIZE check runs only after FastAPI has parsed (and spooled) -the entire multipart body, so oversized/chunked/concurrent requests could exhaust temp -storage before rejection. The middleware bounds ingress before the parser runs. +The middleware (PR #9163 review fix) bounds ingress before any parsing happens, so +oversized, chunked or too-many-concurrent requests are rejected without touching temp +storage at all. The route itself then parses the multipart body and streams the file part +straight into one temp file, enforcing MAX_UPLOAD_SIZE as the bytes arrive — declaring +`file: UploadFile` used to make Starlette spool a second full-size copy first. """ import asyncio @@ -11,10 +13,10 @@ from pathlib import Path from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest -from fastapi import FastAPI, Response, UploadFile +from fastapi import FastAPI, HTTPException, Response from fastapi.testclient import TestClient from starlette.datastructures import Headers @@ -35,10 +37,15 @@ MAX_CONCURRENT = 2 -def test_configured_upload_slots_bound_peak_double_spool_usage() -> None: +def test_configured_upload_slots_bound_peak_temp_storage_usage() -> None: + """Peak temp storage is now one copy per in-flight upload, not two. + + The route parses the multipart body itself and writes the file part straight into its + own temp file, so Starlette's spool no longer holds a second full-size copy. + """ assert videos.MAX_CONCURRENT_VIDEO_UPLOADS <= 2 assert videos.MAX_CONCURRENT_VIDEO_UPLOADS_PER_USER <= 1 - assert 2 * videos.MAX_UPLOAD_SIZE * videos.MAX_CONCURRENT_VIDEO_UPLOADS <= 4 * 1024 * 1024 * 1024 + assert videos.MAX_UPLOAD_SIZE * videos.MAX_CONCURRENT_VIDEO_UPLOADS <= 2 * 1024 * 1024 * 1024 def test_upload_probe_requires_a_decodable_frame(monkeypatch: pytest.MonkeyPatch): @@ -443,6 +450,55 @@ async def send(_message: dict[str, Any]) -> None: assert offloaded == [_identify_video_upload_user] +BOUNDARY = "testboundary" + + +def _multipart_body( + file_bytes: bytes, + filename: str = "video.mp4", + metadata: str | None = None, + content_type: str = "video/mp4", +) -> bytes: + parts = [] + if metadata is not None: + parts.append(f'--{BOUNDARY}\r\nContent-Disposition: form-data; name="metadata"\r\n\r\n{metadata}\r\n'.encode()) + parts.append( + f'--{BOUNDARY}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\n' + f"Content-Type: {content_type}\r\n\r\n".encode() + + file_bytes + + b"\r\n" + ) + parts.append(f"--{BOUNDARY}--\r\n".encode()) + return b"".join(parts) + + +def _fake_upload_request(body: bytes, chunk_size: int = 8) -> MagicMock: + """A Request stand-in that dribbles the body out in small chunks.""" + request = MagicMock() + request.headers = {"content-type": f"multipart/form-data; boundary={BOUNDARY}"} + + async def stream(): + for start in range(0, len(body), chunk_size): + yield body[start : start + chunk_size] + + request.stream = stream + return request + + +def _run_upload(request: MagicMock) -> Any: + return asyncio.run( + upload_video( + current_user=TokenData(user_id="user", email="user@example.com", is_admin=False), + request=request, + response=Response(), + video_category=ImageCategory.GENERAL, + is_intermediate=False, + board_id=None, + session_id=None, + ) + ) + + def test_upload_video_closes_tmp_handle_when_stream_copy_fails(): captured_handles: list[Any] = [] real_named_tmp = tempfile.NamedTemporaryFile @@ -456,30 +512,13 @@ def failing_named_tmp(*args: Any, **kwargs: Any): captured_handles.append(handle) return handle - upload = MagicMock(spec=UploadFile) - upload.filename = "video.mp4" - upload.content_type = "video/mp4" - upload.read = AsyncMock(side_effect=[b"not-empty", b""]) - try: with ( patch("invokeai.app.api.routers.videos.tempfile.NamedTemporaryFile", side_effect=failing_named_tmp), patch("invokeai.app.api.routers.videos.run_in_threadpool", side_effect=run_immediately), pytest.raises(OSError, match="disk full"), ): - asyncio.run( - upload_video( - current_user=TokenData(user_id="user", email="user@example.com", is_admin=False), - file=upload, - request=MagicMock(), - response=Response(), - video_category=ImageCategory.GENERAL, - is_intermediate=False, - board_id=None, - session_id=None, - metadata=None, - ) - ) + _run_upload(_fake_upload_request(_multipart_body(b"not-empty"))) assert len(captured_handles) == 1 assert captured_handles[0].closed @@ -490,6 +529,130 @@ def failing_named_tmp(*args: Any, **kwargs: Any): Path(handle.name).unlink(missing_ok=True) +def test_upload_video_writes_exactly_one_copy_of_the_body(): + """The file part is written straight to the route's temp file, in order, once.""" + payload = bytes(range(256)) * 8 + written: list[bytes] = [] + captured_handles: list[Any] = [] + real_named_tmp = tempfile.NamedTemporaryFile + + async def run_immediately(func: Any, *args: Any): + return func(*args) + + def recording_named_tmp(*args: Any, **kwargs: Any): + handle = real_named_tmp(*args, **kwargs) + real_write = handle.write + handle.write = lambda chunk: (written.append(bytes(chunk)), real_write(chunk))[1] + captured_handles.append(handle) + return handle + + try: + with ( + patch("invokeai.app.api.routers.videos.tempfile.NamedTemporaryFile", side_effect=recording_named_tmp), + patch("invokeai.app.api.routers.videos.run_in_threadpool", side_effect=run_immediately), + patch("invokeai.app.api.routers.videos._is_mp4_file", return_value=False), + pytest.raises(HTTPException) as error, + ): + _run_upload(_fake_upload_request(_multipart_body(payload), chunk_size=13)) + + # Stops at the container check — the point is what reached the disk before that. + assert error.value.status_code == 415 + assert b"".join(written) == payload + finally: + for handle in captured_handles: + handle.close() + Path(handle.name).unlink(missing_ok=True) + + +def test_upload_video_rejects_bad_file_part_without_finishing_the_body(): + """A rejected upload must not require reading the rest of the body first. + + The old route could only reject after Starlette had spooled the whole thing; the + filename/MIME gate now fires from the part headers, before the payload streams in. + """ + consumed: list[int] = [] + body = _multipart_body(b"x" * 4096, filename="notes.txt", content_type="text/plain") + + async def run_immediately(func: Any, *args: Any): + return func(*args) + + request = MagicMock() + request.headers = {"content-type": f"multipart/form-data; boundary={BOUNDARY}"} + + async def stream(): + for start in range(0, len(body), 64): + consumed.append(start) + yield body[start : start + 64] + + request.stream = stream + + with ( + patch("invokeai.app.api.routers.videos.run_in_threadpool", side_effect=run_immediately), + pytest.raises(HTTPException) as error, + ): + _run_upload(request) + + assert error.value.status_code == 415 + # Rejected from the part headers: only the first chunks were ever read. + assert len(consumed) * 64 < len(body) + + +def test_upload_video_requires_a_file_part(): + async def run_immediately(func: Any, *args: Any): + return func(*args) + + body = f'--{BOUNDARY}\r\nContent-Disposition: form-data; name="metadata"\r\n\r\n{{}}\r\n--{BOUNDARY}--\r\n'.encode() + + with ( + patch("invokeai.app.api.routers.videos.run_in_threadpool", side_effect=run_immediately), + pytest.raises(HTTPException) as error, + ): + _run_upload(_fake_upload_request(body)) + + assert error.value.status_code == 422 + + +def test_upload_video_rejects_oversized_file_part_mid_stream(monkeypatch: pytest.MonkeyPatch): + """The size cap fires while the body streams, not after it has all landed on disk.""" + monkeypatch.setattr(videos, "MAX_UPLOAD_SIZE", 512) + written = 0 + + async def run_immediately(func: Any, *args: Any): + return func(*args) + + real_named_tmp = tempfile.NamedTemporaryFile + captured_handles: list[Any] = [] + + def recording_named_tmp(*args: Any, **kwargs: Any): + handle = real_named_tmp(*args, **kwargs) + real_write = handle.write + + def counting_write(chunk: bytes) -> int: + nonlocal written + written += len(chunk) + return real_write(chunk) + + handle.write = counting_write + captured_handles.append(handle) + return handle + + try: + with ( + patch("invokeai.app.api.routers.videos.tempfile.NamedTemporaryFile", side_effect=recording_named_tmp), + patch("invokeai.app.api.routers.videos.run_in_threadpool", side_effect=run_immediately), + pytest.raises(HTTPException) as error, + ): + _run_upload(_fake_upload_request(_multipart_body(b"y" * 4096), chunk_size=64)) + + assert error.value.status_code == 413 + # Only the bytes up to the cap (plus at most one chunk) were ever written. + assert written <= 512 + 64 + finally: + for handle in captured_handles: + handle.close() + Path(handle.name).unlink(missing_ok=True) + + @pytest.mark.parametrize("preserve", [True, False], ids=["preserve", "strip"]) def test_route_matching_is_root_path_aware(preserve: bool): """Behind a sub-path proxy the public path carries the prefix; the size cap must still From 12bdd87e49abfd78b5f1fc41cdd20ac57e427133 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 27 Jul 2026 22:09:25 -0400 Subject: [PATCH 2/2] chore: regenerate openapi.json for the inlined upload_video request body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fields, types and requiredness — the body schema is now inline rather than a Body_upload_video component (nothing references it). Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/frontend/web/openapi.json | 65 ++++++++++++++---------------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 6eb08766f19..4d89dbae438 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -5885,16 +5885,6 @@ "description": "The session ID associated with this upload, if any" } ], - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_upload_video" - } - } - } - }, "responses": { "201": { "description": "The video was uploaded successfully", @@ -5919,6 +5909,37 @@ } } } + }, + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "title": "Body_upload_video", + "type": "object", + "properties": { + "file": { + "title": "File", + "type": "string", + "format": "binary" + }, + "metadata": { + "title": "Metadata", + "description": "The metadata to associate with the video, must be a stringified JSON dict", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["file"] + } + } + } } } }, @@ -15519,30 +15540,6 @@ "required": ["file"], "title": "Body_upload_image" }, - "Body_upload_video": { - "properties": { - "file": { - "type": "string", - "format": "binary", - "title": "File" - }, - "metadata": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Metadata", - "description": "The metadata to associate with the video, must be a stringified JSON dict" - } - }, - "type": "object", - "required": ["file"], - "title": "Body_upload_video" - }, "BooleanCollectionInvocation": { "category": "primitives", "class": "invocation",