From 455dc26859b89f860c1dc0285c301f41d2ec084e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:33:40 +0000 Subject: [PATCH 1/2] Revert "Re-encode a video to a smaller resolution as a new asset" (#55) Each re-encode became a separate asset with a copy of the original's metadata. What is wanted instead is YouTube-style versions: one asset, with standard-resolution files hanging off it. The next commit builds that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V1geD6HjkZNo9XQSHdawSb --- backend/app/enrichment/reencode.py | 195 ------------- backend/app/ingest/filetypes.py | 3 - backend/app/jobs/enrichment.py | 11 - backend/app/jobs/registry.py | 3 +- backend/app/models/job.py | 4 - backend/app/routers/assets.py | 6 +- backend/app/routers/clips.py | 72 +---- backend/app/services/assets.py | 104 ------- backend/tests/test_reencode.py | 257 ------------------ docs/plan-of-attack.md | 7 - frontend/src/api/clips.ts | 22 +- frontend/src/components/ActivityIndicator.tsx | 1 - frontend/src/components/AssetDetail.tsx | 4 - .../src/components/ReencodePanel.test.tsx | 90 ------ frontend/src/components/ReencodePanel.tsx | 94 ------- 15 files changed, 5 insertions(+), 868 deletions(-) delete mode 100644 backend/app/enrichment/reencode.py delete mode 100644 backend/tests/test_reencode.py delete mode 100644 frontend/src/components/ReencodePanel.test.tsx delete mode 100644 frontend/src/components/ReencodePanel.tsx diff --git a/backend/app/enrichment/reencode.py b/backend/app/enrichment/reencode.py deleted file mode 100644 index e6ce463..0000000 --- a/backend/app/enrichment/reencode.py +++ /dev/null @@ -1,195 +0,0 @@ -"""The reencode job: a video's bytes in, a smaller copy of it out, as a new asset. - -For a high-resolution original that is too heavy to cut with in a 720p or 1080p Video -Creator project. The original is never touched; the copy is an ordinary asset with the -original as `parent_asset_id` (provenance only, so it never blocks a delete). - -One ffmpeg command. The only machinery around it is reading ffmpeg's own `-progress` -output, which is not decoration: a 4K hour re-encodes for longer than the stale sweeper's -`job_stale_minutes`, and a job that reports nothing for that long is ended as hung. The -same reports are the cancellation checkpoint, so Cancel stops ffmpeg instead of -leaving it to run to the end on a job nobody is waiting for. - -Runs on a worker thread, so it owns its own session. -""" - -from __future__ import annotations - -import json -import logging -import subprocess -import tempfile -import time -from pathlib import Path -from typing import Callable, Optional - -from sqlmodel import Session - -from app.ingest import thumbnails -from app.ingest.filetypes import TYPE_VIDEO -from app.ingest.probe import probe -from app.media_tools import ffmpeg_available -from app.models.asset import Asset -from app.services import assets as asset_service -from app.storage import build_storage, capacity, new_key, thumb_key_for - -logger = logging.getLogger(__name__) - -Progress = Callable[[str, int, str], None] - -# The frame's short side, in pixels: "1080p" is 1920x1080 landscape and 1080x1920 -# portrait alike. The short side rather than the height because Video Creator renders -# 9:16 and 1:1 as well as 16:9, and a phone's portrait 4K wants to become 1080x1920 for -# a 1080p vertical project, not 608x1080. -TARGETS = (2160, 1440, 1080, 720, 480, 360) - -# How often a progress tick may write to the database. ffmpeg reports twice a second, -# and a row update per report is far more than the activity indicator can show. -_REPORT_EVERY_SECONDS = 2.0 - - -class ReencodeError(Exception): - """Something the user can be told about.""" - - -def label_for(short_side: int) -> str: - return "4K" if short_side == 2160 else f"{short_side}p" - - -def short_side_of(asset: Asset) -> Optional[int]: - if not asset.width or not asset.height: - return None - return min(asset.width, asset.height) - - -def run( - session: Session, asset: Asset, payload: Optional[str], progress: Progress -) -> tuple[str, str]: - """Re-encode `asset` to the payload's `short_side`. Returns (detail, created_asset_id).""" - try: - data = json.loads(payload or "{}") - short_side = int(data["short_side"]) - except (ValueError, KeyError, TypeError) as exc: - raise ReencodeError("Missing or invalid target resolution") from exc - if short_side not in TARGETS: - raise ReencodeError("Unsupported target resolution") - if asset.asset_type != TYPE_VIDEO or not asset.storage_key: - raise ReencodeError("The source file is not available") - # Again, although the router already did: the job may have waited in the queue. - if not capacity.has_room(): - raise ReencodeError(capacity.DISK_FULL_MESSAGE) - - storage = build_storage() - progress("Encoding", 1, "") - - with storage.materialise(asset.storage_key) as source_path: - with tempfile.TemporaryDirectory(prefix="gam-reencode-") as workdir: - target_path = Path(workdir) / "reencode.mp4" - encode(source_path, target_path, short_side, asset.duration_seconds, progress) - - progress("Reading metadata", 92, "") - result = probe(target_path) - preview = thumbnails.generate( - target_path, - TYPE_VIDEO, - asset.original_name or "", - duration_seconds=result.duration_seconds, - ) - - progress("Saving", 95, "") - stored = storage.write_file(new_key(asset.user_id, ".mp4"), target_path) - thumb_key = None - if preview: - thumb_key = storage.write_bytes(thumb_key_for(stored.key), preview).key - - label = label_for(short_side) - created = asset_service.create_reencoded_asset( - session, - source_asset=asset, - stored=stored, - thumb_key=thumb_key, - name=data.get("name") or f"{asset.name} ({label})", - probe_result=result, - ) - return f"{label} copy", created.id - - -def encode( - source: Path, - target: Path, - short_side: int, - duration_seconds: Optional[float], - progress: Progress, -) -> None: - """Scale `source` so its short side is `short_side`, into an H.264/AAC MP4.""" - if not ffmpeg_available(): - raise ReencodeError("ffmpeg is not available on this server") - - # Decided by ffmpeg from the frame it actually decodes, not from the probed - # width/height: a phone video stores landscape pixels plus a rotation flag, ffmpeg - # applies the rotation before the filter runs, and the probe can report either - # orientation. -2 keeps the aspect ratio and rounds to the even size H.264 needs. - # - # setsar=1 because that rounding changes the shape very slightly, and `scale` makes - # up for it with non-square pixels: a 1002x750 source became 480x360 pixels shown - # 481 wide. Video Creator, like most editors, assumes square pixels; a 0.2% squash - # nobody can see is the better trade. - n = short_side - scale = f"scale=w='if(gte(iw,ih),-2,{n})':h='if(gte(iw,ih),{n},-2)',setsar=1" - argv = [ - "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", - "-i", str(source), - "-vf", scale, - # The same codecs and preset as a sub-video's re-encode. CRF 20 is visually - # indistinguishable from the source at the smaller size, which matters for a - # copy that will be cut and re-rendered again downstream. - "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", - "-pix_fmt", "yuv420p", - # Re-encoded rather than copied: an MP4 cannot hold every audio codec a source - # might carry (Vorbis from a WebM, PCM from a camera), and audio costs nothing - # next to the video. - "-c:a", "aac", "-b:a", "192k", - "-movflags", "+faststart", - "-progress", "pipe:1", "-nostats", - "-y", str(target), - ] - stderr_path = target.with_suffix(".log") - with stderr_path.open("w") as stderr_file: - try: - proc = subprocess.Popen( - argv, stdout=subprocess.PIPE, stderr=stderr_file, text=True - ) - except OSError as exc: - raise ReencodeError(f"Could not run ffmpeg: {exc}") from exc - - try: - assert proc.stdout is not None - last_report = 0.0 - for line in proc.stdout: - key, _, value = line.strip().partition("=") - if key != "out_time_us" or time.monotonic() - last_report < _REPORT_EVERY_SECONDS: - continue - last_report = time.monotonic() - done = _seconds(value) - percent = int(done / duration_seconds * 90) if duration_seconds and done else 1 - # Raises JobCancelled once the job is stopped, which unwinds through - # the `finally` below and kills ffmpeg with it. - progress("Encoding", max(1, percent), "") - returncode = proc.wait() - finally: - if proc.poll() is None: - proc.kill() - proc.wait() - - if returncode != 0 or not target.is_file() or target.stat().st_size == 0: - stderr = stderr_path.read_text(errors="replace").strip() - logger.warning("Re-encode of %s failed: %s", source.name, stderr[-400:]) - raise ReencodeError("Could not re-encode this video") - - -def _seconds(out_time_us: str) -> float: - # "N/A" until the first frame is out. - try: - return max(0.0, int(out_time_us) / 1_000_000) - except ValueError: - return 0.0 diff --git a/backend/app/ingest/filetypes.py b/backend/app/ingest/filetypes.py index fe0b326..c978ced 100644 --- a/backend/app/ingest/filetypes.py +++ b/backend/app/ingest/filetypes.py @@ -51,9 +51,6 @@ # flips `source` to `SOURCE_SUBVIDEO` — a real, standalone file from here on. SOURCE_CLIP = "clip" SOURCE_SUBVIDEO = "sub_video" -# A smaller copy of another video (`enrichment/reencode.py`). Its own file, with the -# original as `parent_asset_id` for provenance only, exactly as a sub-video's. -SOURCE_REENCODE = "reencode" # Pillow can decode these directly. The rest of the image set (HEIC, AVIF without a # plugin, SVG) needs something else, so a thumbnail is skipped rather than attempted. diff --git a/backend/app/jobs/enrichment.py b/backend/app/jobs/enrichment.py index 74c9fb1..38e620a 100644 --- a/backend/app/jobs/enrichment.py +++ b/backend/app/jobs/enrichment.py @@ -34,7 +34,6 @@ from app.enrichment.bulk import run as run_bulk from app.enrichment.describe import run as run_describe from app.enrichment.extract_subvideo import run as run_extract_subvideo -from app.enrichment.reencode import ReencodeError, run as run_reencode from app.enrichment.extract_text import TextExtractionError from app.enrichment.extract_text import run as run_extract_text from app.enrichment.generate_all import run as run_generate_all @@ -71,7 +70,6 @@ KIND_DESCRIBE, KIND_EMBED, KIND_EXTRACT_SUBVIDEO, - KIND_REENCODE, KIND_EXTRACT_TEXT, GENERATION_JOB_KINDS, KIND_GENERATE, @@ -320,10 +318,6 @@ def _run(job_id: str, q: JobQueue) -> None: # one of them, so this survives it untouched. if created_asset_id: job.payload = json.dumps({"created_asset_id": created_asset_id}) - elif job.kind == KIND_REENCODE: - detail, created_asset_id = run_reencode(session, asset, job.payload, progress) - # Merged, not replaced: the target size stays readable on the row. - job.payload = _with_created_asset(job.payload, created_asset_id) elif job.kind == KIND_SUMMARIZE: detail = run_summarize(session, asset, progress) elif job.kind == KIND_AUTOTAG: @@ -458,11 +452,6 @@ def _run(job_id: str, q: JobQueue) -> None: set_fields(session, job, status="error", stage="", error_message=message) logger.info("Enrichment job %s failed: %s", job_id, message) - except ReencodeError as exc: - message = str(exc) - set_fields(session, job, status="error", stage="", error_message=message) - logger.info("Enrichment job %s failed: %s", job_id, message) - except SubvideoExtractionError as exc: message = str(exc) _mark_asset_failed(session, asset, job) diff --git a/backend/app/jobs/registry.py b/backend/app/jobs/registry.py index a636b67..0dd8462 100644 --- a/backend/app/jobs/registry.py +++ b/backend/app/jobs/registry.py @@ -26,7 +26,6 @@ KIND_GENERATE, KIND_IMPORT_TRACK, KIND_IMPORT_URL, - KIND_REENCODE, EnrichmentJob, ) from app.schemas_jobs import ActivityJobRead @@ -56,7 +55,7 @@ def __init__( # pointed at the source, and an import or a generation has no asset at all until it is # done. _CREATES_AN_ASSET = frozenset( - {KIND_EXTRACT_SUBVIDEO, KIND_REENCODE, KIND_IMPORT_URL, KIND_IMPORT_TRACK, KIND_GENERATE} + {KIND_EXTRACT_SUBVIDEO, KIND_IMPORT_URL, KIND_IMPORT_TRACK, KIND_GENERATE} ) diff --git a/backend/app/models/job.py b/backend/app/models/job.py index dcd9448..e9511ce 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -45,10 +45,6 @@ # costs nothing, the same reason `KIND_EXTRACT_TEXT` sits apart from the LLM jobs # despite also being per-asset. KIND_EXTRACT_SUBVIDEO = "extract_subvideo" -# Scaling a video down to a smaller frame as a new asset (`enrichment/reencode.py`) — a -# 4K original made workable for a 1080p Video Creator project. Per-asset and outside -# `ENRICHMENT_KINDS` for the reasons an extraction is: one ffmpeg run, no provider. -KIND_REENCODE = "reencode" # M10. Proposing where an asset came from. Inside `ENRICHMENT_KINDS` unlike the two # above: it calls a provider and costs money, so it belongs in the bulk-enrichment # selection UI and its cost estimate. diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index 681443b..ed076c0 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -20,7 +20,6 @@ KIND_ANALYZE_MUSIC_LIBRARY, KIND_EXTRACT_SUBVIDEO, KIND_HARVEST_ATTRIBUTION, - KIND_REENCODE, ) from app.models.tag import Tag from app.schemas import DataResponse, ListResponse @@ -456,11 +455,8 @@ def delete_asset( # `create_subvideo_asset`'s insert trips the parent foreign key — either way after # ffmpeg has already written a file nothing will ever reference. if any( - enrichment_jobs.active_job(session, row.id, kind) is not None + enrichment_jobs.active_job(session, row.id, KIND_EXTRACT_SUBVIDEO) is not None for row in (asset, *clips) - # A re-encode ends the same way as an extract: inserting a row whose parent - # is gone. - for kind in (KIND_EXTRACT_SUBVIDEO, KIND_REENCODE) ): raise HTTPException( status_code=status.HTTP_409_CONFLICT, diff --git a/backend/app/routers/clips.py b/backend/app/routers/clips.py index 55ca424..7e57adc 100644 --- a/backend/app/routers/clips.py +++ b/backend/app/routers/clips.py @@ -1,4 +1,4 @@ -"""Clips (non-destructive), and new files cut or re-encoded from an asset.""" +"""Clips (non-destructive) and sub-video extraction (destructive), over an asset.""" from __future__ import annotations @@ -15,8 +15,7 @@ from app.jobs import enrichment as enrichment_jobs from app.jobs.registry import KINDS from app.models.asset import Asset -from app.enrichment.reencode import TARGETS, label_for, short_side_of -from app.models.job import KIND_EXTRACT_SUBVIDEO, KIND_REENCODE +from app.models.job import KIND_EXTRACT_SUBVIDEO from app.routers.assets import get_storage from app.schemas import DataResponse, ListResponse from app.schemas_assets import AssetRead @@ -58,14 +57,6 @@ class ClipCreate(BaseModel): name: Optional[str] = Field(default=None, min_length=1, max_length=255) -class ReencodeCreate(BaseModel): - # The frame's short side in pixels: 1080 is 1920x1080 or 1080x1920. Checked against - # `TARGETS` in the route rather than typed as a Literal, so a bad value gets the - # house error shape instead of a bare 422. - short_side: int - name: Optional[str] = Field(default=None, min_length=1, max_length=255) - - class ClipRangeUpdate(BaseModel): in_point: float = Field(ge=0) out_point: float = Field(gt=0) @@ -239,62 +230,3 @@ def promote_clip( session, clip, KIND_EXTRACT_SUBVIDEO, payload=json.dumps({"mode": "promote"}) ) return DataResponse(data=KINDS["enrichment"].to_activity(job)) - - -# ─── re-encoding to a smaller frame ───────────────────────────────────────────── - - -@router.post("/{asset_id}/reencode", response_model=DataResponse[ActivityJobRead], status_code=202) -def start_reencode( - asset_id: str, - payload: ReencodeCreate, - user: CurrentUser, - session: Session = Depends(get_session), -) -> DataResponse[ActivityJobRead]: - """Queue a smaller copy of a video as a new asset. The original is untouched.""" - asset = _owned_asset(asset_id, user.id, session) - if asset.asset_type != TYPE_VIDEO or not asset.storage_key: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "code": "not_reencodable", - "message": "Only a video that owns a file can be re-encoded", - }, - ) - if payload.short_side not in TARGETS: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "code": "bad_request", - "message": "Choose one of " + ", ".join(label_for(t) for t in TARGETS), - }, - ) - # Down only. Scaling up adds bytes and no detail, and Video Creator already scales - # a small source up to its frame at render time for free. A video with no recorded - # size is let through: ffmpeg will still do what was asked. - current = short_side_of(asset) - if current is not None and payload.short_side >= current: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "code": "not_smaller", - "message": f"This video is already {current}p or smaller", - }, - ) - if enrichment_jobs.active_job(session, asset.id, KIND_REENCODE) is not None: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail={ - "code": "already_running", - "message": "A re-encode is already running for this video", - }, - ) - capacity.require_room() - - job = enrichment_jobs.submit( - session, - asset, - KIND_REENCODE, - payload=json.dumps({"short_side": payload.short_side, "name": payload.name}), - ) - return DataResponse(data=KINDS["enrichment"].to_activity(job)) diff --git a/backend/app/services/assets.py b/backend/app/services/assets.py index 4e706a6..0afae44 100644 --- a/backend/app/services/assets.py +++ b/backend/app/services/assets.py @@ -25,7 +25,6 @@ from app.ingest.filetypes import ( SOURCE_AI, SOURCE_CLIP, - SOURCE_REENCODE, SOURCE_SUBVIDEO, SOURCE_UPLOAD, TYPE_AUDIO, @@ -45,7 +44,6 @@ Asset, ) from app.models.suggestion import Suggestion -from app.models.tag import AssetTag from app.models.document import DocumentPage from app.models.transcript import TranscriptSegment from app.schemas_assets import ( @@ -832,108 +830,6 @@ def create_subvideo_asset( return subvideo -# What a re-encoded copy takes from its original besides the file. Everything about what -# the video *is* rather than how its bytes are laid out, since the usual next step after -# downsizing is deleting the original to get the space back, and a delete only clears -# the copy's `parent_asset_id`: anything left on the original goes with it. The -# timeline is unchanged by a re-encode, so the transcript's timestamps stay true. -_CARRIED_TO_A_COPY: tuple[str, ...] = ( - "description", - "summary", - "transcript_status", - "transcript_model", - "transcript_language", - "field_provenance", - "ai_model", - "ai_generation_type", - "ai_prompt", - "ai_parameters", - "ai_source_assets", - "ai_seed", - "ai_generated_at", -) - - -def create_reencoded_asset( - session: Session, - *, - source_asset: Asset, - stored: StoredFile, - thumb_key: Optional[str], - name: str, - probe_result: ProbeResult, -) -> Asset: - """A smaller copy of `source_asset`: a brand-new, standalone Asset. - - Self-sufficient from the start (see `_CARRIED_TO_A_COPY`): attribution is written - onto the copy *resolved*, not left to inherit through `parent_asset_id`, and tags - and the transcript are copied rows. `parent_asset_id` is provenance only, as a - sub-video's, so the original can be deleted the moment this exists. - """ - copy = Asset( - user_id=source_asset.user_id, - name=name, - asset_type=source_asset.asset_type, - source=SOURCE_REENCODE, - storage_key=stored.key, - thumb_key=thumb_key, - parent_asset_id=source_asset.id, - original_name=source_asset.original_name, - mime_type=mimetypes.guess_type(stored.key)[0], - file_format=extension_of(stored.key).lstrip("."), - size_bytes=stored.size_bytes, - checksum_sha256=stored.sha256, - duration_seconds=probe_result.duration_seconds or source_asset.duration_seconds, - width=probe_result.width, - height=probe_result.height, - codec=probe_result.codec, - ) - for field in _CARRIED_TO_A_COPY: - setattr(copy, field, getattr(source_asset, field)) - grandparent = ( - session.get(Asset, source_asset.parent_asset_id) if source_asset.parent_asset_id else None - ) - resolved = attribution.resolve(source_asset, grandparent) - for field in attribution.ATTRIBUTION_FIELDS: - setattr(copy, field, getattr(resolved, field)) - session.add(copy) - session.flush() - - for tag in tags.tags_for(session, source_asset.id): - session.add(AssetTag(asset_id=copy.id, tag_id=tag.id)) - segments = session.exec( - select(TranscriptSegment) - .where(TranscriptSegment.asset_id == source_asset.id) - .order_by(col(TranscriptSegment.idx)) - ).all() - copied_segments = [ - TranscriptSegment( - asset_id=copy.id, - user_id=segment.user_id, - idx=segment.idx, - text=segment.text, - start_time=segment.start_time, - end_time=segment.end_time, - speaker=segment.speaker, - words=segment.words, - edited=segment.edited, - ) - for segment in segments - ] - session.add_all(copied_segments) - session.commit() - session.refresh(copy) - - _reindex(session, copy) - if copied_segments: - try: - fts.index_segments(session, copy.id, copied_segments) - session.commit() - except Exception: # noqa: BLE001 - a transcript that exists beats one indexed - logger.warning("Could not index the copied transcript of %s", copy.id, exc_info=True) - return copy - - def promote_clip( session: Session, clip: Asset, *, stored: StoredFile, thumb_key: Optional[str], probe_result: ProbeResult ) -> Asset: diff --git a/backend/tests/test_reencode.py b/backend/tests/test_reencode.py deleted file mode 100644 index b19fcf5..0000000 --- a/backend/tests/test_reencode.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Re-encoding a video to a smaller frame, as a new asset.""" - -import json -import subprocess -from pathlib import Path - -import pytest -from sqlmodel import select - -from app.enrichment.reencode import ReencodeError, encode, run as run_reencode -from app.ingest.probe import probe -from app.jobs.runner import JobCancelled -from app.media_tools import ffmpeg_available -from app.models.asset import Asset -from app.models.job import EnrichmentJob, KIND_REENCODE -from app.models.transcript import TranscriptSegment - -FIXTURES = Path(__file__).parent / "fixtures" - -needs_ffmpeg = pytest.mark.skipif(not ffmpeg_available(), reason="ffmpeg not on PATH") - - -def _make_video(path: Path, width: int, height: int, seconds: float = 1.0) -> Path: - subprocess.run( - [ - "ffmpeg", "-nostdin", "-v", "error", - "-f", "lavfi", "-i", f"testsrc=size={width}x{height}:rate=25:duration={seconds}", - "-f", "lavfi", "-i", f"sine=duration={seconds}", - "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-shortest", - "-y", str(path), - ], - check=True, - ) - return path - - -def _upload(client, path: Path) -> dict: - return client.post( - "/api/assets", files=[("files", (path.name, path.read_bytes(), "video/mp4"))] - ).json()["created"][0] - - -def _upload_fixture(client, name: str, mime: str) -> dict: - return client.post( - "/api/assets", files=[("files", (name, (FIXTURES / name).read_bytes(), mime))] - ).json()["created"][0] - - -def _pretend_size(session, asset_id: str, width: int, height: int) -> None: - row = session.get(Asset, asset_id) - row.width, row.height = width, height - session.add(row) - session.commit() - - -# ─── the route ───────────────────────────────────────────────────────────────── - - -def test_reencode_queues_a_job(library, session): - video = _upload_fixture(library, "sample_video.mp4", "video/mp4") - _pretend_size(session, video["id"], 3840, 2160) - - response = library.post(f"/api/assets/{video['id']}/reencode", json={"short_side": 1080}) - assert response.status_code == 202 - job = response.json()["data"] - assert job["action"] == "reencode" - assert job["asset_id"] == video["id"] - - -def test_reencode_refuses_a_size_that_is_not_smaller(library): - # The fixture is 320x240: nothing on the list is smaller. - video = _upload_fixture(library, "sample_video.mp4", "video/mp4") - response = library.post(f"/api/assets/{video['id']}/reencode", json={"short_side": 360}) - assert response.status_code == 400 - assert response.json()["detail"]["code"] == "not_smaller" - - -def test_reencode_refuses_an_unlisted_size(library, session): - video = _upload_fixture(library, "sample_video.mp4", "video/mp4") - _pretend_size(session, video["id"], 3840, 2160) - response = library.post(f"/api/assets/{video['id']}/reencode", json={"short_side": 1000}) - assert response.status_code == 400 - assert response.json()["detail"]["code"] == "bad_request" - - -def test_reencode_refuses_audio(library): - audio = _upload_fixture(library, "sample_audio.mp3", "audio/mpeg") - response = library.post(f"/api/assets/{audio['id']}/reencode", json={"short_side": 720}) - assert response.status_code == 400 - assert response.json()["detail"]["code"] == "not_reencodable" - - -def test_reencode_refuses_a_second_concurrent_run(library, session): - video = _upload_fixture(library, "sample_video.mp4", "video/mp4") - _pretend_size(session, video["id"], 3840, 2160) - library.post(f"/api/assets/{video['id']}/reencode", json={"short_side": 1080}) - - response = library.post(f"/api/assets/{video['id']}/reencode", json={"short_side": 720}) - assert response.status_code == 409 - - -def test_reencode_of_someone_elses_video_is_a_404(library, session): - other = Asset(user_id="somebody-else", name="Theirs", asset_type="video", source="local_upload") - session.add(other) - session.commit() - response = library.post(f"/api/assets/{other.id}/reencode", json={"short_side": 720}) - assert response.status_code == 404 - - -def test_a_running_reencode_blocks_deleting_its_source(library, session): - video = _upload_fixture(library, "sample_video.mp4", "video/mp4") - _pretend_size(session, video["id"], 3840, 2160) - library.post(f"/api/assets/{video['id']}/reencode", json={"short_side": 1080}) - - response = library.delete(f"/api/assets/{video['id']}") - assert response.status_code == 409 - assert response.json()["detail"]["code"] == "extraction_in_progress" - - -# ─── running it for real ─────────────────────────────────────────────────────── - - -@needs_ffmpeg -def test_running_creates_a_smaller_standalone_copy(library, session, tmp_path): - video = _upload(library, _make_video(tmp_path / "wide.mp4", 1280, 720)) - source = session.get(Asset, video["id"]) - source_key = source.storage_key - - detail, created_id = run_reencode( - session, source, json.dumps({"short_side": 360}), lambda *a, **k: None - ) - - assert detail == "360p copy" - copy = session.get(Asset, created_id) - assert (copy.width, copy.height) == (640, 360) - assert copy.source == "reencode" - assert copy.parent_asset_id == source.id - assert copy.storage_key and copy.storage_key != source_key - assert copy.name == f"{source.name} (360p)" - # The original is untouched. - session.refresh(source) - assert source.storage_key == source_key - assert (source.width, source.height) == (1280, 720) - - -@needs_ffmpeg -def test_the_copy_keeps_everything_once_the_original_is_deleted(library, session, tmp_path): - """The workflow this exists for: downsize, then delete the original for the space.""" - video = _upload(library, _make_video(tmp_path / "wide.mp4", 1280, 720)) - library.patch( - f"/api/assets/{video['id']}", - json={"description": "Harbour at dawn", "creator": "A. Filmer", "publisher": "Gecko"}, - ) - r = library.post(f"/api/assets/{video['id']}/tags", json={"names": ["harbour", "b-roll"]}) - assert r.status_code == 200, r.text - source = session.get(Asset, video["id"]) - source.transcript_status = "done" - session.add(source) - session.add( - TranscriptSegment( - asset_id=source.id, user_id=source.user_id, idx=0, - text="the boats come in", start_time=0.1, end_time=0.9, - ) - ) - session.commit() - - _, created_id = run_reencode( - session, source, json.dumps({"short_side": 360}), lambda *a, **k: None - ) - assert library.delete(f"/api/assets/{video['id']}").status_code in (200, 204) - - copy = library.get(f"/api/assets/{created_id}").json()["data"] - assert copy["parent_asset_id"] is None # the delete cleared the breadcrumb - assert copy["description"] == "Harbour at dawn" - assert copy["creator"] == "A. Filmer" - assert copy["publisher"] == "Gecko" - assert sorted(t["name"] for t in copy["tags"]) == ["b-roll", "harbour"] - segments = session.exec( - select(TranscriptSegment).where(TranscriptSegment.asset_id == created_id) - ).all() - assert [s.text for s in segments] == ["the boats come in"] - # Found by what was said, not only by its name. - hits = library.get("/api/search", params={"q": "boats"}).json() - assert created_id in json.dumps(hits) - - -@needs_ffmpeg -def test_the_target_is_the_short_side_of_a_portrait_video(tmp_path): - source = _make_video(tmp_path / "tall.mp4", 720, 1280) - target = tmp_path / "out.mp4" - - encode(source, target, 360, 1.0, lambda *a, **k: None) - - result = probe(target) - assert (result.width, result.height) == (360, 640) - - -@needs_ffmpeg -def test_an_odd_aspect_rounds_to_an_even_width(tmp_path): - # 1000x750 at 360 tall is 480 wide; 1001x750 would be 480.48 — H.264 needs even. - source = _make_video(tmp_path / "odd.mp4", 1002, 750) - target = tmp_path / "out.mp4" - - encode(source, target, 360, 1.0, lambda *a, **k: None) - - result = probe(target) - assert result.height == 360 - assert result.width % 2 == 0 - - -@needs_ffmpeg -def test_cancelling_stops_ffmpeg_and_creates_nothing(library, session, tmp_path, monkeypatch): - from app.enrichment import reencode - - # Report on every line, so the cancel lands while ffmpeg is still running. - monkeypatch.setattr(reencode, "_REPORT_EVERY_SECONDS", 0.0) - video = _upload(library, _make_video(tmp_path / "long.mp4", 1280, 720, seconds=20)) - source = session.get(Asset, video["id"]) - - def cancelled(*_args, **_kwargs): - raise JobCancelled("job") - - with pytest.raises(JobCancelled): - run_reencode(session, source, json.dumps({"short_side": 360}), cancelled) - assert session.exec(select(Asset).where(Asset.source == "reencode")).all() == [] - - -def test_a_garbage_file_is_a_readable_error(tmp_path): - junk = tmp_path / "junk.mp4" - junk.write_bytes(b"not a video") - with pytest.raises(ReencodeError, match="Could not re-encode"): - encode(junk, tmp_path / "out.mp4", 360, 1.0, lambda *a, **k: None) - - -def test_a_full_disk_stops_a_queued_reencode(library, session, disk): - video = _upload_fixture(library, "sample_video.mp4", "video/mp4") - source = session.get(Asset, video["id"]) - disk.set(free_gb=1) - - with pytest.raises(ReencodeError, match="disk is nearly full"): - run_reencode(session, source, json.dumps({"short_side": 360}), lambda *a, **k: None) - - -def test_the_finished_job_reports_the_new_asset(library, session): - from app.jobs.registry import KINDS - - video = _upload_fixture(library, "sample_video.mp4", "video/mp4") - job = EnrichmentJob( - user_id="user-under-test", - asset_id=video["id"], - kind=KIND_REENCODE, - status="done", - payload=json.dumps({"short_side": 720, "created_asset_id": "made-this"}), - ) - session.add(job) - session.commit() - assert KINDS["enrichment"].to_activity(job).result_asset_id == "made-this" diff --git a/docs/plan-of-attack.md b/docs/plan-of-attack.md index 379c267..fe3fa6e 100644 --- a/docs/plan-of-attack.md +++ b/docs/plan-of-attack.md @@ -349,13 +349,6 @@ CSP's Notes origin follow `NOTES_BASE_URL`. rework — full-bleed intrinsic grid, the resizable docked panel, `DetailDock`/`Tabs`, and `/a/{id}` as a page. Also non-milestone: M1 built that UI for one screen size and M3–M6 accreted panels onto it without revisiting it. -Re-encoding to a smaller frame (`enrichment/reencode.py`, `POST /api/assets/{id}/reencode`, -the Info tab's "Smaller copy") is also non-milestone: a 4K original made workable for a -1080p or 720p Video Creator project. The target is the frame's *short side*, because GVC -renders 9:16 and 1:1 too, and only sizes below the source's are offered. The copy is a new -asset (`source = "reencode"`) carrying the original's tags, resolved attribution and -transcript, since the usual next step is deleting the original for the space, and a -delete only clears the copy's `parent_asset_id`. ### Outstanding, unscheduled diff --git a/frontend/src/api/clips.ts b/frontend/src/api/clips.ts index bd193df..10c3d47 100644 --- a/frontend/src/api/clips.ts +++ b/frontend/src/api/clips.ts @@ -3,8 +3,7 @@ import type { Asset } from '@/api/assets' import type { ActivityJob } from '@/api/transcripts' /** - * Clips (non-destructive), and new files cut or re-encoded from an asset, over - * `routers/clips.py`. + * Clips (non-destructive) and sub-video extraction (destructive), over `routers/clips.py`. * * One `xApi` per backend router, the house convention — `Asset`/`ActivityJob` are * imported rather than redeclared, since `api/assets.ts` and `api/transcripts.ts` @@ -17,14 +16,6 @@ export interface ClipRange { name?: string } -/** The frame's short side in pixels: 1080 is 1920×1080 landscape or 1080×1920 - * portrait. Mirrors `enrichment/reencode.py::TARGETS`. */ -export const REENCODE_TARGETS = [2160, 1440, 1080, 720, 480, 360] as const - -export function reencodeLabel(shortSide: number): string { - return shortSide === 2160 ? '4K' : `${shortSide}p` -} - interface DataResponse { data: T } @@ -77,15 +68,4 @@ export const clipsApi = { .post>(`/assets/${clipId}/promote`) .then((r) => r.data.data) }, - - /** Queues a smaller copy as a new asset, carrying the original's tags, attribution - * and transcript so the original can be deleted afterwards. Its id lands on the - * finished job as `result_asset_id`. */ - reencode(assetId: string, shortSide: number): Promise { - return client - .post>(`/assets/${assetId}/reencode`, { - short_side: shortSide, - }) - .then((r) => r.data.data) - }, } diff --git a/frontend/src/components/ActivityIndicator.tsx b/frontend/src/components/ActivityIndicator.tsx index f639c6c..201c0d2 100644 --- a/frontend/src/components/ActivityIndicator.tsx +++ b/frontend/src/components/ActivityIndicator.tsx @@ -20,7 +20,6 @@ const LABELS: Record = { autotag: 'Suggesting tags', extract_text: 'Reading text', import_url: 'Importing', - reencode: 'Re-encoding', import_track: 'Importing a track', generate: 'Generating', harvest_attribution: 'Reading file metadata', diff --git a/frontend/src/components/AssetDetail.tsx b/frontend/src/components/AssetDetail.tsx index 9098e1f..cfb4d82 100644 --- a/frontend/src/components/AssetDetail.tsx +++ b/frontend/src/components/AssetDetail.tsx @@ -42,7 +42,6 @@ import EmbedButton from '@/components/EmbedButton' import EnrichmentButton from '@/components/EnrichmentButton' import GenerateTab from '@/components/GenerateTab' import MusicPanel from '@/components/MusicPanel' -import ReencodePanel from '@/components/ReencodePanel' import SuggestionPanel from '@/components/SuggestionPanel' import TagInput from '@/components/TagInput' import Tabs, { type TabSpec } from '@/components/Tabs' @@ -516,9 +515,6 @@ export default function AssetDetail({ /> )} - {asset.asset_type === 'video' && !ownsNoFile(asset) && ( - - )}
diff --git a/frontend/src/components/ReencodePanel.test.tsx b/frontend/src/components/ReencodePanel.test.tsx deleted file mode 100644 index e56d40e..0000000 --- a/frontend/src/components/ReencodePanel.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import ReencodePanel from '@/components/ReencodePanel' -import { clipsApi } from '@/api/clips' -import { activityApi, type ActivityJob } from '@/api/transcripts' -import type { Asset } from '@/api/assets' -import { useLibraryStore } from '@/stores/library' -import { noAttribution } from '@/test-fixtures' - -function makeVideo(overrides: Partial = {}): Asset { - return { - id: 'a1', - name: 'Harbour', - description: null, - summary: null, - asset_type: 'video', - source: 'local_upload', - parent_asset_id: null, - in_point: null, - out_point: null, - original_name: null, - mime_type: null, - file_format: null, - size_bytes: 0, - duration_seconds: 60, - width: 3840, - height: 2160, - codec: null, - file_url: null, - thumb_url: null, - missing: false, - tags: [], - ...noAttribution, - upload_date: '2026-01-01T00:00:00', - modified_date: '2026-01-01T00:00:00', - metadata_modified_date: '2026-01-01T00:00:00', - ...overrides, - } -} - -function renderPanel(asset: Asset) { - return render( - - - - ) -} - -beforeEach(() => { - useLibraryStore.getState().reset() - vi.spyOn(activityApi, 'list').mockResolvedValue([]) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('ReencodePanel', () => { - it('offers only sizes smaller than the video, defaulting to 1080p', () => { - renderPanel(makeVideo()) - const options = screen.getAllByRole('option').map((o) => o.textContent) - expect(options).toEqual(['1440p', '1080p', '720p', '480p', '360p']) - expect(screen.getByRole('combobox')).toHaveValue('1080') - }) - - it('reads a portrait video by its short side', () => { - renderPanel(makeVideo({ width: 1080, height: 1920 })) - const options = screen.getAllByRole('option').map((o) => o.textContent) - expect(options).toEqual(['720p', '480p', '360p']) - }) - - it('renders nothing for a video already at the smallest size', () => { - const { container } = renderPanel(makeVideo({ width: 640, height: 360 })) - expect(container).toBeEmptyDOMElement() - }) - - it('sends the chosen size', async () => { - const reencode = vi - .spyOn(clipsApi, 'reencode') - .mockResolvedValue({ id: 'j1' } as ActivityJob) - renderPanel(makeVideo()) - - await userEvent.selectOptions(screen.getByRole('combobox'), '720') - await userEvent.click(screen.getByRole('button', { name: 'Re-encode to 720p' })) - - await waitFor(() => expect(reencode).toHaveBeenCalledWith('a1', 720)) - }) -}) diff --git a/frontend/src/components/ReencodePanel.tsx b/frontend/src/components/ReencodePanel.tsx deleted file mode 100644 index a9695d6..0000000 --- a/frontend/src/components/ReencodePanel.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useEffect, useState } from 'react' -import { Link } from 'react-router-dom' -import { Minimize2 } from 'lucide-react' -import type { Asset } from '@/api/assets' -import { REENCODE_TARGETS, clipsApi, reencodeLabel } from '@/api/clips' -import type { ActivityJob } from '@/api/transcripts' -import { useLibraryStore } from '@/stores/library' -import EnrichmentButton from '@/components/EnrichmentButton' - -/** - * Make a smaller copy of a video, as a new asset: a 4K original that is too heavy for a - * 1080p Video Creator project. - * - * Only sizes below the video's own are offered, the same rule the server enforces — - * scaling up adds bytes and no detail. The copy carries the original's tags, - * attribution and transcript, so the original can be deleted once the copy is checked; - * nothing here deletes it, since that is the one step that cannot be undone. - */ - -interface Props { - asset: Asset -} - -export default function ReencodePanel({ asset }: Props) { - const shortSide = - asset.width && asset.height ? Math.min(asset.width, asset.height) : null - const targets = REENCODE_TARGETS.filter((t) => shortSide === null || t < shortSide) - const preferred = targets.includes(1080) ? 1080 : targets[0] - const [target, setTarget] = useState(preferred) - const [created, setCreated] = useState<{ id: string; name: string } | null>(null) - const openById = useLibraryStore((s) => s.openById) - - // Re-seed when a different asset opens in the same panel. - useEffect(() => { - setTarget(preferred) - setCreated(null) - }, [asset.id, preferred]) - - if (target === undefined) return null - - const onFinish = (job: ActivityJob) => { - if (job.status !== 'done' || !job.result_asset_id) return - // Into the store, so the copy has a card in the grid before the library reloads. - void openById(job.result_asset_id).then((fresh) => { - setCreated({ id: fresh.id, name: fresh.name }) - }) - } - - return ( -
-
- - -
- clipsApi.reencode(id, target)} - failureMessage="Could not start re-encoding" - refreshOnFinish={false} - onFinish={onFinish} - /> - {created && ( -

- Created “{created.name}” —{' '} - - view it - - . Its tags, credit and transcript came with it, so this original can be deleted - once you have checked it. -

- )} -
- ) -} From eedf95be638e371129e0c64b9989f479c737da2b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:43:10 +0000 Subject: [PATCH 2/2] Add standard-resolution versions of a video, YouTube-style A video asset gets a Versions tab: the original, and a row per standard size below it (4K, 1440p, 1080p, 720p, 480p, 360p), each made by hand with Create. A version is a file of the one asset (new `assetversion` table), not an asset of its own, so there is nothing to copy or keep in step: name, tags, attribution, transcript and clips stay on the asset. - One ffmpeg run in the job queue (`make_version`), the encode from #55: short side as the target (portrait 4K -> 1080x1920), setsar=1 for square pixels, and ffmpeg's -progress driving percent, liveness and Cancel. - "Make original" swaps a version's file in as the asset's own and deletes the big original: how space comes back. Same row and id, so tags, transcript and clips carry on; the timeline does not change. - Deleting the asset deletes its versions and their files. A version being made blocks the delete and make-original. - File counts and sizes: versions are their own group in the storage panel and in the library total, "Largest files" ranks by original plus versions and says how much is versions, and the stray sweep counts version files as referenced rather than deleting them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V1geD6HjkZNo9XQSHdawSb --- .../20261029_1300_add_asset_versions.py | 54 +++ backend/app/enrichment/versions.py | 178 +++++++++ backend/app/jobs/enrichment.py | 9 + backend/app/main.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/job.py | 4 + backend/app/models/version.py | 42 ++ backend/app/routers/assets.py | 6 +- backend/app/routers/versions.py | 189 +++++++++ backend/app/schemas_storage.py | 8 + backend/app/schemas_versions.py | 29 ++ backend/app/services/assets.py | 25 +- backend/app/services/storage_usage.py | 44 ++- backend/app/services/versions.py | 135 +++++++ backend/tests/test_storage_usage.py | 2 + backend/tests/test_versions.py | 368 ++++++++++++++++++ docs/plan-of-attack.md | 12 + frontend/src/api/storage.ts | 6 + frontend/src/api/versions.ts | 69 ++++ frontend/src/components/ActivityIndicator.tsx | 1 + frontend/src/components/AssetDetail.tsx | 14 + frontend/src/components/StoragePanel.test.tsx | 51 ++- frontend/src/components/StoragePanel.tsx | 14 +- frontend/src/components/VersionsTab.test.tsx | 157 ++++++++ frontend/src/components/VersionsTab.tsx | 294 ++++++++++++++ frontend/src/views/SettingsView.test.tsx | 1 + 26 files changed, 1700 insertions(+), 16 deletions(-) create mode 100644 backend/alembic/versions/20261029_1300_add_asset_versions.py create mode 100644 backend/app/enrichment/versions.py create mode 100644 backend/app/models/version.py create mode 100644 backend/app/routers/versions.py create mode 100644 backend/app/schemas_versions.py create mode 100644 backend/app/services/versions.py create mode 100644 backend/tests/test_versions.py create mode 100644 frontend/src/api/versions.ts create mode 100644 frontend/src/components/VersionsTab.test.tsx create mode 100644 frontend/src/components/VersionsTab.tsx diff --git a/backend/alembic/versions/20261029_1300_add_asset_versions.py b/backend/alembic/versions/20261029_1300_add_asset_versions.py new file mode 100644 index 0000000..e060b69 --- /dev/null +++ b/backend/alembic/versions/20261029_1300_add_asset_versions.py @@ -0,0 +1,54 @@ +"""add assetversion: standard-resolution files of a video + +A video's 1080p, 720p and so on, beside its original (app/models/version.py). Rows of +their own rather than Asset rows, so a version shares its asset's name, tags, +attribution and transcript instead of carrying a copy. A new table only: nothing +existing changes, and the downgrade drops it whole. + +Revision ID: aecf97cd87b1 +Revises: f3a7d2c18e54 +Create Date: 2026-10-29 04:34:10.980967+00:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision: str = 'aecf97cd87b1' +down_revision: Union[str, None] = 'f3a7d2c18e54' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('assetversion', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('asset_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('short_side', sa.Integer(), nullable=False), + sa.Column('storage_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('mime_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('size_bytes', sa.Integer(), nullable=False), + sa.Column('checksum_sha256', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('width', sa.Integer(), nullable=True), + sa.Column('height', sa.Integer(), nullable=True), + sa.Column('codec', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['asset_id'], ['asset.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('asset_id', 'short_side', name='ux_assetversion_size') + ) + with op.batch_alter_table('assetversion', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_assetversion_asset_id'), ['asset_id'], unique=False) + batch_op.create_index(batch_op.f('ix_assetversion_user_id'), ['user_id'], unique=False) + + + +def downgrade() -> None: + with op.batch_alter_table('assetversion', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_assetversion_user_id')) + batch_op.drop_index(batch_op.f('ix_assetversion_asset_id')) + + op.drop_table('assetversion') diff --git a/backend/app/enrichment/versions.py b/backend/app/enrichment/versions.py new file mode 100644 index 0000000..09e08dd --- /dev/null +++ b/backend/app/enrichment/versions.py @@ -0,0 +1,178 @@ +"""The make_version job: a video's original in, one standard-resolution file of it out. + +YouTube's model: an asset keeps its original, and has whichever standard sizes (1080p, +720p, ...) someone has asked for, each a file of its own (`models/version.py`). Made by +hand, one at a time, from the asset's Versions tab. A 4K original made workable for a +1080p Video Creator project is what this is for. + +One ffmpeg command. The only machinery around it is reading ffmpeg's own `-progress` +output, which is not decoration: a 4K hour encodes for longer than the stale sweeper's +`job_stale_minutes`, and a job that reports nothing for that long is ended as hung. The +same reports are the cancellation checkpoint, so Cancel stops ffmpeg instead of +leaving it to run to the end on a job nobody is waiting for. + +Runs on a worker thread, so it owns its own session. +""" + +from __future__ import annotations + +import json +import logging +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Callable, Optional + +from sqlmodel import Session + +from app.ingest.filetypes import TYPE_VIDEO +from app.ingest.probe import probe +from app.media_tools import ffmpeg_available +from app.models.asset import Asset +from app.services import versions as version_service +from app.storage import build_storage, capacity, new_key + +logger = logging.getLogger(__name__) + +Progress = Callable[[str, int, str], None] + +# The standard sizes, as the frame's short side in pixels: "1080p" is 1920x1080 +# landscape and 1080x1920 portrait alike. The short side rather than the height because +# Video Creator renders 9:16 and 1:1 as well as 16:9, and a phone's portrait 4K wants to +# become 1080x1920 for a 1080p vertical project, not 608x1080. +SIZES = (2160, 1440, 1080, 720, 480, 360) + +# How often a progress tick may write to the database. ffmpeg reports twice a second, +# and a row update per report is far more than the activity indicator can show. +_REPORT_EVERY_SECONDS = 2.0 + + +class VersionError(Exception): + """Something the user can be told about.""" + + +def label_for(short_side: int) -> str: + return "4K" if short_side == 2160 else f"{short_side}p" + + +def short_side_of(asset: Asset) -> Optional[int]: + if not asset.width or not asset.height: + return None + return min(asset.width, asset.height) + + +def run(session: Session, asset: Asset, payload: Optional[str], progress: Progress) -> str: + """Make the payload's `short_side` version of `asset`. Returns the job's detail.""" + try: + short_side = int(json.loads(payload or "{}")["short_side"]) + except (ValueError, KeyError, TypeError) as exc: + raise VersionError("Missing or invalid size") from exc + if short_side not in SIZES: + raise VersionError("Unsupported size") + if asset.asset_type != TYPE_VIDEO or not asset.storage_key: + raise VersionError("The original file is not available") + # Again, although the router already did: the job may have waited in the queue. + if version_service.find(session, asset.id, short_side) is not None: + raise VersionError(f"This video already has a {label_for(short_side)} version") + if not capacity.has_room(): + raise VersionError(capacity.DISK_FULL_MESSAGE) + + storage = build_storage() + progress("Encoding", 1, "") + + with storage.materialise(asset.storage_key) as source_path: + with tempfile.TemporaryDirectory(prefix="gam-version-") as workdir: + target_path = Path(workdir) / "version.mp4" + encode(source_path, target_path, short_side, asset.duration_seconds, progress) + + progress("Saving", 95, "") + result = probe(target_path) + stored = storage.write_file(new_key(asset.user_id, ".mp4"), target_path) + + version_service.create( + session, asset=asset, short_side=short_side, stored=stored, probe_result=result + ) + return f"{label_for(short_side)} version" + + +def encode( + source: Path, + target: Path, + short_side: int, + duration_seconds: Optional[float], + progress: Progress, +) -> None: + """Scale `source` so its short side is `short_side`, into an H.264/AAC MP4.""" + if not ffmpeg_available(): + raise VersionError("ffmpeg is not available on this server") + + # Decided by ffmpeg from the frame it actually decodes, not from the probed + # width/height: a phone video stores landscape pixels plus a rotation flag, ffmpeg + # applies the rotation before the filter runs, and the probe can report either + # orientation. -2 keeps the aspect ratio and rounds to the even size H.264 needs. + # + # setsar=1 because that rounding changes the shape very slightly, and `scale` makes + # up for it with non-square pixels: a 1002x750 source became 480x360 pixels shown + # 481 wide. Video Creator, like most editors, assumes square pixels; a 0.2% squash + # nobody can see is the better trade. + n = short_side + scale = f"scale=w='if(gte(iw,ih),-2,{n})':h='if(gte(iw,ih),{n},-2)',setsar=1" + argv = [ + "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", + "-i", str(source), + "-vf", scale, + # The same codec and preset as a sub-video's re-encode. CRF 20 is visually + # indistinguishable from the source at the smaller size, which matters for a + # file that will be cut and rendered again downstream — and that can become + # the original itself (`services/versions.py::make_original`). + "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", + "-pix_fmt", "yuv420p", + # Re-encoded rather than copied: an MP4 cannot hold every audio codec a source + # might carry (Vorbis from a WebM, PCM from a camera), and audio costs nothing + # next to the video. + "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", + "-progress", "pipe:1", "-nostats", + "-y", str(target), + ] + stderr_path = target.with_suffix(".log") + with stderr_path.open("w") as stderr_file: + try: + proc = subprocess.Popen( + argv, stdout=subprocess.PIPE, stderr=stderr_file, text=True + ) + except OSError as exc: + raise VersionError(f"Could not run ffmpeg: {exc}") from exc + + try: + assert proc.stdout is not None + last_report = 0.0 + for line in proc.stdout: + key, _, value = line.strip().partition("=") + if key != "out_time_us" or time.monotonic() - last_report < _REPORT_EVERY_SECONDS: + continue + last_report = time.monotonic() + done = _seconds(value) + percent = int(done / duration_seconds * 90) if duration_seconds and done else 1 + # Raises JobCancelled once the job is stopped, which unwinds through + # the `finally` below and kills ffmpeg with it. + progress("Encoding", max(1, percent), "") + returncode = proc.wait() + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + + if returncode != 0 or not target.is_file() or target.stat().st_size == 0: + stderr = stderr_path.read_text(errors="replace").strip() + logger.warning("Encoding a version of %s failed: %s", source.name, stderr[-400:]) + raise VersionError("Could not encode this video") + + +def _seconds(out_time_us: str) -> float: + # "N/A" until the first frame is out. + try: + return max(0.0, int(out_time_us) / 1_000_000) + except ValueError: + return 0.0 diff --git a/backend/app/jobs/enrichment.py b/backend/app/jobs/enrichment.py index 38e620a..981f2c3 100644 --- a/backend/app/jobs/enrichment.py +++ b/backend/app/jobs/enrichment.py @@ -34,6 +34,7 @@ from app.enrichment.bulk import run as run_bulk from app.enrichment.describe import run as run_describe from app.enrichment.extract_subvideo import run as run_extract_subvideo +from app.enrichment.versions import VersionError, run as run_make_version from app.enrichment.extract_text import TextExtractionError from app.enrichment.extract_text import run as run_extract_text from app.enrichment.generate_all import run as run_generate_all @@ -70,6 +71,7 @@ KIND_DESCRIBE, KIND_EMBED, KIND_EXTRACT_SUBVIDEO, + KIND_MAKE_VERSION, KIND_EXTRACT_TEXT, GENERATION_JOB_KINDS, KIND_GENERATE, @@ -318,6 +320,8 @@ def _run(job_id: str, q: JobQueue) -> None: # one of them, so this survives it untouched. if created_asset_id: job.payload = json.dumps({"created_asset_id": created_asset_id}) + elif job.kind == KIND_MAKE_VERSION: + detail = run_make_version(session, asset, job.payload, progress) elif job.kind == KIND_SUMMARIZE: detail = run_summarize(session, asset, progress) elif job.kind == KIND_AUTOTAG: @@ -452,6 +456,11 @@ def _run(job_id: str, q: JobQueue) -> None: set_fields(session, job, status="error", stage="", error_message=message) logger.info("Enrichment job %s failed: %s", job_id, message) + except VersionError as exc: + message = str(exc) + set_fields(session, job, status="error", stage="", error_message=message) + logger.info("Enrichment job %s failed: %s", job_id, message) + except SubvideoExtractionError as exc: message = str(exc) _mark_asset_failed(session, asset, job) diff --git a/backend/app/main.py b/backend/app/main.py index 6be159a..dfe41f5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -37,6 +37,7 @@ from app.routers import storage as storage_router from app.routers import tags as tags_router from app.routers import usage as usage_router +from app.routers import versions as versions_router from app.routers import transcripts as transcripts_router from app.schemas import ClientConfig, DataResponse, HealthResponse @@ -213,6 +214,7 @@ def me( app.include_router(transcripts_router.router, prefix="/api/assets", tags=["transcripts"]) app.include_router(enrichment_router.router, prefix="/api/assets", tags=["enrichment"]) app.include_router(clips_router.router, prefix="/api/assets", tags=["clips"]) +app.include_router(versions_router.router, prefix="/api/assets", tags=["versions"]) app.include_router(imports_router.router, prefix="/api/assets", tags=["imports"]) app.include_router(activity_router.router, prefix="/api/activity", tags=["activity"]) # Its own tree rather than under /api/assets: the catalogue is not about any asset, and a diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 04ed2c2..3cfd01e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -21,11 +21,13 @@ from app.models.transcript import TranscriptSegment from app.models.usage import UsageEvent from app.models.user import User +from app.models.version import AssetVersion __all__ = [ "AIProvider", "Asset", "AssetTag", + "AssetVersion", "DocumentPage", "Embedding", "EnrichmentJob", diff --git a/backend/app/models/job.py b/backend/app/models/job.py index e9511ce..7a31c2d 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -45,6 +45,10 @@ # costs nothing, the same reason `KIND_EXTRACT_TEXT` sits apart from the LLM jobs # despite also being per-asset. KIND_EXTRACT_SUBVIDEO = "extract_subvideo" +# A standard-resolution version of a video: its 1080p, its 720p (`enrichment/versions.py`). +# Per-asset and outside `ENRICHMENT_KINDS` for the reasons an extraction is: one ffmpeg +# run, no provider. Which size rides in `payload`. +KIND_MAKE_VERSION = "make_version" # M10. Proposing where an asset came from. Inside `ENRICHMENT_KINDS` unlike the two # above: it calls a provider and costs money, so it belongs in the bulk-enrichment # selection UI and its cost estimate. diff --git a/backend/app/models/version.py b/backend/app/models/version.py new file mode 100644 index 0000000..8aa2cfe --- /dev/null +++ b/backend/app/models/version.py @@ -0,0 +1,42 @@ +"""Standard-resolution versions of a video: the 1080p, the 720p, beside the original. + +A row per file rather than new `Asset` rows, because a version is not a separate thing +in the library. It has no name, tags, attribution or transcript of its own; it is the +same video at another size, so all of that stays on the one asset and nothing has to be +copied or kept in step. YouTube's model: the original, plus whichever sizes have been +made from it. +""" + +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import UniqueConstraint +from sqlmodel import Field, SQLModel + +from app.clock import utcnow + + +class AssetVersion(SQLModel, table=True): + # One file per size per asset. Making a size that exists is refused by the router + # before it queues, and this is what makes that a guarantee rather than a check. + __table_args__ = (UniqueConstraint("asset_id", "short_side", name="ux_assetversion_size"),) + + id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) + asset_id: str = Field(foreign_key="asset.id", index=True) + user_id: str = Field(index=True) + + # The standard size this is, as the frame's short side in pixels: 1080 is 1920x1080 + # and 1080x1920 alike (see `enrichment/versions.py::SIZES`). The real dimensions + # are below; this is the label it was asked for. + short_side: int + + storage_key: str + mime_type: Optional[str] = None + size_bytes: int = Field(default=0) + checksum_sha256: Optional[str] = None + width: Optional[int] = None + height: Optional[int] = None + codec: Optional[str] = None + + created_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index ed076c0..b1677a8 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -20,6 +20,7 @@ KIND_ANALYZE_MUSIC_LIBRARY, KIND_EXTRACT_SUBVIDEO, KIND_HARVEST_ATTRIBUTION, + KIND_MAKE_VERSION, ) from app.models.tag import Tag from app.schemas import DataResponse, ListResponse @@ -455,8 +456,11 @@ def delete_asset( # `create_subvideo_asset`'s insert trips the parent foreign key — either way after # ffmpeg has already written a file nothing will ever reference. if any( - enrichment_jobs.active_job(session, row.id, KIND_EXTRACT_SUBVIDEO) is not None + enrichment_jobs.active_job(session, row.id, kind) is not None for row in (asset, *clips) + # A version being made ends the same way: a row whose asset is gone, and a + # file nothing points at. + for kind in (KIND_EXTRACT_SUBVIDEO, KIND_MAKE_VERSION) ): raise HTTPException( status_code=status.HTTP_409_CONFLICT, diff --git a/backend/app/routers/versions.py b/backend/app/routers/versions.py new file mode 100644 index 0000000..b2d3cc4 --- /dev/null +++ b/backend/app/routers/versions.py @@ -0,0 +1,189 @@ +"""A video's standard-resolution versions: list, make, delete, and make one the original.""" + +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlmodel import Session + +from app.auth import CurrentUser +from app.database import get_session +from app.enrichment.versions import SIZES, label_for, short_side_of +from app.ingest.filetypes import TYPE_VIDEO +from app.jobs import enrichment as enrichment_jobs +from app.jobs.registry import KINDS +from app.models.asset import Asset +from app.models.job import KIND_EXTRACT_SUBVIDEO, KIND_MAKE_VERSION +from app.models.version import AssetVersion +from app.routers.assets import get_storage +from app.schemas import DataResponse, ListResponse +from app.schemas_assets import AssetRead +from app.schemas_jobs import ActivityJobRead +from app.schemas_versions import VersionCreate, VersionRead +from app.services import assets as asset_service +from app.services import tags as tag_service +from app.services import versions as version_service +from app.storage import LocalStorage, capacity + +router = APIRouter() + + +def _owned_video(asset_id: str, user_id: str, session: Session) -> Asset: + asset = session.get(Asset, asset_id) + if not asset or asset.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "not_found", "message": "No such asset"}, + ) + if asset.asset_type != TYPE_VIDEO or not asset.storage_key: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "not_versionable", + "message": "Only a video that owns a file has versions", + }, + ) + return asset + + +def _owned_version(asset: Asset, version_id: str, session: Session) -> AssetVersion: + version = session.get(AssetVersion, version_id) + if not version or version.asset_id != asset.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "not_found", "message": "No such version"}, + ) + return version + + +def _require_no_version_job(session: Session, asset_id: str) -> None: + # One at a time per asset. It also guards `make_original`: a queued job reads the + # original's file when it starts, and swapping the original underneath it would + # have it encode the version that was just made the original. + if enrichment_jobs.active_job(session, asset_id, KIND_MAKE_VERSION) is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "already_running", + "message": "A version of this video is already being made", + }, + ) + + +@router.get("/{asset_id}/versions", response_model=ListResponse[VersionRead]) +def list_versions( + asset_id: str, user: CurrentUser, session: Session = Depends(get_session) +) -> ListResponse[VersionRead]: + asset = _owned_video(asset_id, user.id, session) + rows = version_service.list_for(session, asset.id) + return ListResponse( + data=[version_service.to_read_model(row) for row in rows], + total=len(rows), + limit=len(rows), + offset=0, + ) + + +@router.post( + "/{asset_id}/versions", response_model=DataResponse[ActivityJobRead], status_code=202 +) +def start_version( + asset_id: str, + payload: VersionCreate, + user: CurrentUser, + session: Session = Depends(get_session), +) -> DataResponse[ActivityJobRead]: + """Queue one standard-resolution version. The original is untouched.""" + asset = _owned_video(asset_id, user.id, session) + if payload.short_side not in SIZES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "bad_request", + "message": "Choose one of " + ", ".join(label_for(s) for s in SIZES), + }, + ) + # Down only. Scaling up adds bytes and no detail, and Video Creator already scales + # a small source up to its frame at render time. A video with no recorded size is + # let through: ffmpeg will still do what was asked. + current = short_side_of(asset) + if current is not None and payload.short_side >= current: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "not_smaller", + "message": f"This video is already {label_for(current)} or smaller", + }, + ) + if version_service.find(session, asset.id, payload.short_side) is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "version_exists", + "message": f"This video already has a {label_for(payload.short_side)} version", + }, + ) + _require_no_version_job(session, asset.id) + capacity.require_room() + + job = enrichment_jobs.submit( + session, + asset, + KIND_MAKE_VERSION, + payload=json.dumps({"short_side": payload.short_side}), + ) + return DataResponse(data=KINDS["enrichment"].to_activity(job)) + + +@router.delete( + "/{asset_id}/versions/{version_id}", + status_code=status.HTTP_204_NO_CONTENT, + response_model=None, + response_class=Response, +) +def delete_version( + asset_id: str, + version_id: str, + user: CurrentUser, + session: Session = Depends(get_session), + storage: LocalStorage = Depends(get_storage), +) -> Response: + asset = _owned_video(asset_id, user.id, session) + version_service.delete(session, storage, _owned_version(asset, version_id, session)) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post("/{asset_id}/versions/{version_id}/make-original", response_model=DataResponse[AssetRead]) +def make_original( + asset_id: str, + version_id: str, + user: CurrentUser, + session: Session = Depends(get_session), + storage: LocalStorage = Depends(get_storage), +) -> DataResponse[AssetRead]: + """Make this version the asset's file and delete the original it replaces. + + Irreversible, which is the point: it is how a library gets the space back. The + frontend confirms first. + """ + asset = _owned_video(asset_id, user.id, session) + version = _owned_version(asset, version_id, session) + _require_no_version_job(session, asset.id) + # An extraction cutting from this asset reads its file when it starts, so the + # file must not be deleted from under one that is queued or running. + if enrichment_jobs.active_job(session, asset.id, KIND_EXTRACT_SUBVIDEO) is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "extraction_in_progress", + "message": "A sub-video is being cut from this video. Let it finish first.", + }, + ) + asset = version_service.make_original(session, storage, asset, version) + parent = session.get(Asset, asset.parent_asset_id) if asset.parent_asset_id else None + return DataResponse( + data=asset_service.to_read_model( + asset, storage, tag_service.tags_for(session, asset.id), parent=parent + ) + ) diff --git a/backend/app/schemas_storage.py b/backend/app/schemas_storage.py index 7b79e62..99fd744 100644 --- a/backend/app/schemas_storage.py +++ b/backend/app/schemas_storage.py @@ -22,7 +22,11 @@ class LargestAsset(BaseModel): id: str name: str asset_type: str + # The original's bytes alone, as the asset's own `size_bytes` reads. size_bytes: int + # What its standard-resolution versions add on top. Ranked by the sum of the two, + # since deleting the asset frees both. + versions_bytes: int = 0 class LibraryUsage(BaseModel): @@ -31,6 +35,10 @@ class LibraryUsage(BaseModel): total_bytes: int files: int by_type: List[TypeUsage] + # Standard-resolution versions (1080p, 720p, ...) of videos. Their own group rather + # than folded into "video", so the panel can say how much is originals and how much + # is copies that could be deleted without losing a video. + versions: FileGroup = FileGroup() thumbnails: FileGroup largest: List[LargestAsset] diff --git a/backend/app/schemas_versions.py b/backend/app/schemas_versions.py new file mode 100644 index 0000000..78b5ec4 --- /dev/null +++ b/backend/app/schemas_versions.py @@ -0,0 +1,29 @@ +"""What `/api/assets/{id}/versions` returns.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +class VersionRead(BaseModel): + id: str + asset_id: str + # The standard size it was made as: the frame's short side in pixels. + short_side: int + width: Optional[int] = None + height: Optional[int] = None + size_bytes: int + codec: Optional[str] = None + mime_type: Optional[str] = None + # Signed, like an asset's own `file_url`. + file_url: Optional[str] = None + created_at: datetime + + +class VersionCreate(BaseModel): + # Checked against `enrichment/versions.py::SIZES` in the route rather than typed as + # a Literal, so a bad value gets the house error shape instead of a bare 422. + short_side: int diff --git a/backend/app/services/assets.py b/backend/app/services/assets.py index 0afae44..5d0ece3 100644 --- a/backend/app/services/assets.py +++ b/backend/app/services/assets.py @@ -46,6 +46,7 @@ from app.models.suggestion import Suggestion from app.models.document import DocumentPage from app.models.transcript import TranscriptSegment +from app.models.version import AssetVersion from app.schemas_assets import ( AssetGenerationRead, AssetGenerationSource, @@ -450,9 +451,11 @@ def _delete_dependents(session: Session, asset_id: str) -> None: session.exec(delete(Suggestion).where(col(Suggestion.asset_id) == asset_id)) session.exec(delete(TranscriptSegment).where(col(TranscriptSegment.asset_id) == asset_id)) session.exec(delete(DocumentPage).where(col(DocumentPage.asset_id) == asset_id)) + # A foreign key, like the tag join. Their files are unlinked by `delete_asset`. + session.exec(delete(AssetVersion).where(col(AssetVersion.asset_id) == asset_id)) -def _still_referenced(session: Session, user_id: str, key: str) -> bool: +def still_referenced(session: Session, user_id: str, key: str) -> bool: """Whether any surviving row still points at this storage object. A key is not owned by exactly one row. A clip's `thumb_key` is a copy of its @@ -488,7 +491,7 @@ def delete_asset( The row goes first. If the unlink fails the asset is still gone from the user's view, and an orphaned file is recoverable by a sweep; the reverse — a row pointing at nothing — is what produces broken images. "Only it was using" is - `_still_referenced`: a poster is routinely shared between a parent and its clips. + `still_referenced`: a poster is routinely shared between a parent and its clips. The dependent rows have to go before it, for two different reasons. Tag attachments are a hard constraint: `AssetTag.asset_id` is a foreign key with no @@ -520,6 +523,16 @@ def delete_asset( doomed_ids = [row.id for row in doomed] keys = {key for row in doomed for key in (row.storage_key, row.thumb_key) if key} file_keys = {row.storage_key for row in doomed if row.storage_key} + # Read before the rows go. A version's file is its own, never shared, so it needs + # no `still_referenced` check — but it goes through the same loop to get its cached + # waveform removed with it. + version_keys = set( + session.exec( + select(AssetVersion.storage_key).where(col(AssetVersion.asset_id).in_(doomed_ids)) + ).all() + ) + keys |= version_keys + file_keys |= version_keys asset_id, user_id = asset.id, asset.user_id for row_id in doomed_ids: @@ -553,7 +566,7 @@ def delete_asset( # unlinked file that something still shows is not recoverable at all. for key in keys: try: - if _still_referenced(session, user_id, key): + if still_referenced(session, user_id, key): continue except Exception: # noqa: BLE001 - see above logger.warning("Could not check whether %s is still in use", key, exc_info=True) @@ -964,8 +977,8 @@ def to_read_model( and signs like any other asset's. """ playable_key = asset.storage_key if asset.storage_key else (parent.storage_key if parent else None) - file_url = _signed_url(playable_key) - thumb_url = _signed_url(asset.thumb_key) + file_url = signed_url(playable_key) + thumb_url = signed_url(asset.thumb_key) missing = False if playable_key: @@ -1068,7 +1081,7 @@ def _json_or(text: Optional[str], default: Any) -> Any: return value if isinstance(value, type(default)) else default -def _signed_url(key: Optional[str]) -> Optional[str]: +def signed_url(key: Optional[str]) -> Optional[str]: if not key: return None expires_at, signature = sign_media_key(key, ttl_seconds=settings.media_url_ttl_seconds) diff --git a/backend/app/services/storage_usage.py b/backend/app/services/storage_usage.py index e5f2086..6f20559 100644 --- a/backend/app/services/storage_usage.py +++ b/backend/app/services/storage_usage.py @@ -23,6 +23,7 @@ from app.config import settings from app.models.asset import Asset +from app.models.version import AssetVersion from app.schemas_storage import ( CleanupResult, FileGroup, @@ -74,21 +75,44 @@ def library_usage(session: Session, storage: LocalStorage, user_id: str) -> Libr thumbnails.files += 1 thumbnails.bytes += stat.st_size + version_count, version_bytes = session.exec( + select(func.count(), func.coalesce(func.sum(AssetVersion.size_bytes), 0)).where( + AssetVersion.user_id == user_id + ) + ).one() + versions = FileGroup(files=version_count, bytes=version_bytes) + + # Per asset, so "largest" ranks what a delete would actually free: the original + # and every version of it. + per_asset = ( + select( + AssetVersion.asset_id, + func.sum(AssetVersion.size_bytes).label("versions_bytes"), + ) + .where(AssetVersion.user_id == user_id) + .group_by(AssetVersion.asset_id) + .subquery() + ) + versions_of = func.coalesce(per_asset.c.versions_bytes, 0) largest = [ - LargestAsset(id=asset_id, name=name, asset_type=kind, size_bytes=size) - for asset_id, name, kind, size in session.exec( - select(Asset.id, Asset.name, Asset.asset_type, Asset.size_bytes) + LargestAsset( + id=asset_id, name=name, asset_type=kind, size_bytes=size, versions_bytes=extra + ) + for asset_id, name, kind, size, extra in session.exec( + select(Asset.id, Asset.name, Asset.asset_type, Asset.size_bytes, versions_of) + .outerjoin(per_asset, per_asset.c.asset_id == Asset.id) .where(*owns_file) - .order_by(col(Asset.size_bytes).desc(), col(Asset.id)) + .order_by((col(Asset.size_bytes) + versions_of).desc(), col(Asset.id)) .limit(LARGEST_LIMIT) ).all() ] originals = sum(row.bytes for row in by_type) return LibraryUsage( - total_bytes=originals + thumbnails.bytes, - files=sum(row.files for row in by_type), + total_bytes=originals + versions.bytes + thumbnails.bytes, + files=sum(row.files for row in by_type) + versions.files, by_type=by_type, + versions=versions, thumbnails=thumbnails, largest=largest, ) @@ -203,6 +227,14 @@ def _referenced_keys(session: Session) -> set[str]: pass if thumb_key: keys.add(thumb_key) + # A version's file is in no `Asset` column at all, so without this every one of + # them would be swept as a stray the first time an admin cleaned up. + for version_key in session.exec(select(AssetVersion.storage_key)).all(): + keys.add(version_key) + try: + keys.add(peaks_key_for(version_key)) + except StorageError: + pass return keys diff --git a/backend/app/services/versions.py b/backend/app/services/versions.py new file mode 100644 index 0000000..291c7e5 --- /dev/null +++ b/backend/app/services/versions.py @@ -0,0 +1,135 @@ +"""A video's standard-resolution versions: listing, saving, deleting, and swapping one in. + +`make_original` is how a library frees space without losing anything. A 4K original can +be replaced by its own 1080p version: the version's file becomes the asset's file, and +the big one is deleted. Same row, same id, so tags, attribution, transcript, clips and +every link to the asset carry on as they were. The timeline does not change with the +size, so a clip's in and out points and the transcript's timestamps stay true. +""" + +from __future__ import annotations + +import logging +import mimetypes +from typing import Optional + +from sqlmodel import Session, col, select + +from app.clock import utcnow +from app.ingest.filetypes import extension_of +from app.ingest.probe import ProbeResult +from app.models.asset import Asset +from app.models.version import AssetVersion +from app.schemas_versions import VersionRead +from app.services import assets as asset_service +from app.storage import LocalStorage, StoredFile, peaks_key_for + +logger = logging.getLogger(__name__) + + +def find(session: Session, asset_id: str, short_side: int) -> Optional[AssetVersion]: + return session.exec( + select(AssetVersion).where( + AssetVersion.asset_id == asset_id, AssetVersion.short_side == short_side + ) + ).first() + + +def list_for(session: Session, asset_id: str) -> list[AssetVersion]: + """Largest first, the order a size picker reads in.""" + return list( + session.exec( + select(AssetVersion) + .where(AssetVersion.asset_id == asset_id) + .order_by(col(AssetVersion.short_side).desc()) + ).all() + ) + + +def create( + session: Session, + *, + asset: Asset, + short_side: int, + stored: StoredFile, + probe_result: ProbeResult, +) -> AssetVersion: + version = AssetVersion( + asset_id=asset.id, + user_id=asset.user_id, + short_side=short_side, + storage_key=stored.key, + mime_type=mimetypes.guess_type(stored.key)[0], + size_bytes=stored.size_bytes, + checksum_sha256=stored.sha256, + width=probe_result.width, + height=probe_result.height, + codec=probe_result.codec, + ) + session.add(version) + session.commit() + session.refresh(version) + return version + + +def delete(session: Session, storage: LocalStorage, version: AssetVersion) -> None: + """Row first, then the file, for the reason `assets.delete_asset` gives.""" + key = version.storage_key + session.delete(version) + session.commit() + storage.delete(key) + storage.delete(peaks_key_for(key)) + + +def make_original( + session: Session, storage: LocalStorage, asset: Asset, version: AssetVersion +) -> Asset: + """Swap `version`'s file in as `asset`'s own, and delete the file it replaces. + + The poster is kept rather than regenerated: it is a picture of the same frame, and + `thumb_key` is a column of its own, so it does not go with the old file. The other + versions are kept too, a larger one included: each was made from the original, so + each is still a true copy of this video. + """ + old_key = asset.storage_key + asset.storage_key = version.storage_key + asset.mime_type = version.mime_type + asset.file_format = extension_of(version.storage_key).lstrip(".") + asset.size_bytes = version.size_bytes + asset.checksum_sha256 = version.checksum_sha256 + asset.width = version.width + asset.height = version.height + asset.codec = version.codec + asset.metadata_modified_date = utcnow() + session.add(asset) + session.delete(version) + session.commit() + session.refresh(asset) + + # After the commit, as in `delete_asset`: a row pointing at a deleted file is the + # broken state; a file nothing points at is what the stray sweep is for. + if old_key: + try: + referenced = asset_service.still_referenced(session, asset.user_id, old_key) + except Exception: # noqa: BLE001 - keep the file when unsure + logger.warning("Could not check whether %s is still in use", old_key, exc_info=True) + referenced = True + if not referenced: + storage.delete(old_key) + storage.delete(peaks_key_for(old_key)) + return asset + + +def to_read_model(version: AssetVersion) -> VersionRead: + return VersionRead( + id=version.id, + asset_id=version.asset_id, + short_side=version.short_side, + width=version.width, + height=version.height, + size_bytes=version.size_bytes, + codec=version.codec, + mime_type=version.mime_type, + file_url=asset_service.signed_url(version.storage_key), + created_at=version.created_at, + ) diff --git a/backend/tests/test_storage_usage.py b/backend/tests/test_storage_usage.py index 66dd99a..a5b25ab 100644 --- a/backend/tests/test_storage_usage.py +++ b/backend/tests/test_storage_usage.py @@ -121,6 +121,7 @@ def test_the_largest_are_my_ten_biggest_files_biggest_first(library, session): "name": rows[-1].name, "asset_type": "video", "size_bytes": 12_000_000, + "versions_bytes": 0, } @@ -131,6 +132,7 @@ def test_an_empty_library_is_all_zeros(library): "total_bytes": 0, "files": 0, "by_type": [], + "versions": {"files": 0, "bytes": 0}, "thumbnails": {"files": 0, "bytes": 0}, "largest": [], } diff --git a/backend/tests/test_versions.py b/backend/tests/test_versions.py new file mode 100644 index 0000000..7f24229 --- /dev/null +++ b/backend/tests/test_versions.py @@ -0,0 +1,368 @@ +"""Standard-resolution versions of a video: the 1080p, the 720p, beside the original.""" + +import json +import os +import subprocess +import time +import uuid +from pathlib import Path + +import pytest +from sqlmodel import select + +from app.config import settings +from app.enrichment.versions import VersionError, encode, run as run_make_version +from app.ingest.probe import ProbeResult, probe +from app.jobs.runner import JobCancelled +from app.media_tools import ffmpeg_available +from app.models.asset import Asset +from app.models.version import AssetVersion +from app.services import versions as version_service +from app.storage import LocalStorage +from app.storage.base import StoredFile + +FIXTURES = Path(__file__).parent / "fixtures" +TEST_USER = "user-under-test" + +needs_ffmpeg = pytest.mark.skipif(not ffmpeg_available(), reason="ffmpeg not on PATH") + + +def _make_video(path: Path, width: int, height: int, seconds: float = 1.0) -> Path: + subprocess.run( + [ + "ffmpeg", "-nostdin", "-v", "error", + "-f", "lavfi", "-i", f"testsrc=size={width}x{height}:rate=25:duration={seconds}", + "-f", "lavfi", "-i", f"sine=duration={seconds}", + "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-shortest", + "-y", str(path), + ], + check=True, + ) + return path + + +def _upload(client, path: Path, mime: str = "video/mp4") -> dict: + response = client.post("/api/assets", files=[("files", (path.name, path.read_bytes(), mime))]) + assert response.status_code == 201, response.text + return response.json()["created"][0] + + +def _fixture_video(client) -> dict: + return _upload(client, FIXTURES / "sample_video.mp4") + + +def _pretend_size(session, asset_id: str, width: int, height: int) -> None: + row = session.get(Asset, asset_id) + row.width, row.height = width, height + session.add(row) + session.commit() + + +def _fake_version(session, media_dir: Path, asset_id: str, short_side: int, size: int = 500) -> AssetVersion: + """A version row with a real file behind it, without spending an ffmpeg run.""" + asset = session.get(Asset, asset_id) + key = f"{asset.user_id}/{uuid.uuid4()}.mp4" + stored = LocalStorage(media_dir).write_bytes(key, b"v" * size) + return version_service.create( + session, + asset=asset, + short_side=short_side, + stored=stored, + probe_result=ProbeResult(width=short_side * 16 // 9, height=short_side, codec="h264"), + ) + + +def _versions(client, asset_id: str) -> list[dict]: + response = client.get(f"/api/assets/{asset_id}/versions") + assert response.status_code == 200, response.text + return response.json()["data"] + + +# ─── making one (the route) ──────────────────────────────────────────────────── + + +def test_making_a_version_queues_a_job(library, session): + video = _fixture_video(library) + _pretend_size(session, video["id"], 3840, 2160) + + response = library.post(f"/api/assets/{video['id']}/versions", json={"short_side": 1080}) + assert response.status_code == 202, response.text + job = response.json()["data"] + assert job["action"] == "make_version" + assert job["asset_id"] == video["id"] + + +def test_a_size_that_is_not_smaller_is_refused(library): + # The fixture is 320x240: nothing on the list is smaller. + video = _fixture_video(library) + response = library.post(f"/api/assets/{video['id']}/versions", json={"short_side": 360}) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "not_smaller" + + +def test_a_size_off_the_list_is_refused(library, session): + video = _fixture_video(library) + _pretend_size(session, video["id"], 3840, 2160) + response = library.post(f"/api/assets/{video['id']}/versions", json={"short_side": 1000}) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "bad_request" + + +def test_a_size_it_already_has_is_refused(library, session, media_dir): + video = _fixture_video(library) + _pretend_size(session, video["id"], 3840, 2160) + _fake_version(session, media_dir, video["id"], 1080) + + response = library.post(f"/api/assets/{video['id']}/versions", json={"short_side": 1080}) + assert response.status_code == 409 + assert response.json()["detail"]["code"] == "version_exists" + + +def test_audio_has_no_versions(library): + audio = _upload(library, FIXTURES / "sample_audio.mp3", "audio/mpeg") + response = library.post(f"/api/assets/{audio['id']}/versions", json={"short_side": 720}) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "not_versionable" + + +def test_one_version_at_a_time(library, session): + video = _fixture_video(library) + _pretend_size(session, video["id"], 3840, 2160) + library.post(f"/api/assets/{video['id']}/versions", json={"short_side": 1080}) + + response = library.post(f"/api/assets/{video['id']}/versions", json={"short_side": 720}) + assert response.status_code == 409 + assert response.json()["detail"]["code"] == "already_running" + + +def test_someone_elses_video_is_a_404(library, session): + other = Asset( + user_id="somebody-else", name="Theirs", asset_type="video", + source="local_upload", storage_key="somebody-else/x.mp4", + ) + session.add(other) + session.commit() + assert library.get(f"/api/assets/{other.id}/versions").status_code == 404 + response = library.post(f"/api/assets/{other.id}/versions", json={"short_side": 720}) + assert response.status_code == 404 + + +def test_a_version_being_made_blocks_deleting_the_video(library, session): + video = _fixture_video(library) + _pretend_size(session, video["id"], 3840, 2160) + library.post(f"/api/assets/{video['id']}/versions", json={"short_side": 1080}) + + response = library.delete(f"/api/assets/{video['id']}") + assert response.status_code == 409 + assert response.json()["detail"]["code"] == "extraction_in_progress" + + +# ─── making one (the job, for real) ──────────────────────────────────────────── + + +@needs_ffmpeg +def test_the_job_adds_a_version_and_leaves_the_original_alone(library, session, tmp_path): + video = _upload(library, _make_video(tmp_path / "wide.mp4", 1280, 720)) + original_key = session.get(Asset, video["id"]).storage_key + + detail = run_make_version( + session, session.get(Asset, video["id"]), json.dumps({"short_side": 360}), + lambda *a, **k: None, + ) + + assert detail == "360p version" + [version] = _versions(library, video["id"]) + assert (version["short_side"], version["width"], version["height"]) == (360, 640, 360) + assert version["size_bytes"] > 0 + # Served like any asset file. + assert library.get(version["file_url"]).status_code == 200 + # Still one asset, with the same file. + assert session.exec(select(Asset).where(Asset.asset_type == "video")).all() == [ + session.get(Asset, video["id"]) + ] + assert session.get(Asset, video["id"]).storage_key == original_key + + +@needs_ffmpeg +def test_the_size_is_the_short_side_of_a_portrait_video(tmp_path): + target = tmp_path / "out.mp4" + encode(_make_video(tmp_path / "tall.mp4", 720, 1280), target, 360, 1.0, lambda *a, **k: None) + result = probe(target) + assert (result.width, result.height) == (360, 640) + + +@needs_ffmpeg +def test_an_odd_aspect_comes_out_even_with_square_pixels(tmp_path): + # 1002x750 at 360 high is 480.96 wide. Without setsar=1 ffmpeg writes 480 pixels + # stretched to display as 481, which is what a probe reports. + target = tmp_path / "out.mp4" + encode(_make_video(tmp_path / "odd.mp4", 1002, 750), target, 360, 1.0, lambda *a, **k: None) + result = probe(target) + assert (result.width, result.height) == (480, 360) + + +@needs_ffmpeg +def test_cancelling_stops_ffmpeg_and_saves_nothing(library, session, tmp_path, monkeypatch, media_dir): + from app.enrichment import versions + + # Report on every line, so the cancel lands while ffmpeg is still running. + monkeypatch.setattr(versions, "_REPORT_EVERY_SECONDS", 0.0) + video = _upload(library, _make_video(tmp_path / "long.mp4", 1280, 720, seconds=20)) + files_before = sorted(p.name for p in (media_dir / TEST_USER).iterdir()) + + def cancelled(*_args, **_kwargs): + raise JobCancelled("job") + + with pytest.raises(JobCancelled): + run_make_version( + session, session.get(Asset, video["id"]), json.dumps({"short_side": 360}), cancelled + ) + assert _versions(library, video["id"]) == [] + assert sorted(p.name for p in (media_dir / TEST_USER).iterdir()) == files_before + + +def test_a_garbage_file_is_a_readable_error(tmp_path): + junk = tmp_path / "junk.mp4" + junk.write_bytes(b"not a video") + with pytest.raises(VersionError, match="Could not encode"): + encode(junk, tmp_path / "out.mp4", 360, 1.0, lambda *a, **k: None) + + +def test_a_full_disk_stops_a_queued_version(library, session, disk): + video = _fixture_video(library) + disk.set(free_gb=1) + with pytest.raises(VersionError, match="disk is nearly full"): + run_make_version( + session, session.get(Asset, video["id"]), json.dumps({"short_side": 360}), + lambda *a, **k: None, + ) + + +# ─── deleting ────────────────────────────────────────────────────────────────── + + +def test_deleting_a_version_removes_its_file(library, session, media_dir): + video = _fixture_video(library) + version = _fake_version(session, media_dir, video["id"], 720) + path = media_dir / version.storage_key + + response = library.delete(f"/api/assets/{video['id']}/versions/{version.id}") + + assert response.status_code == 204 + assert not path.exists() + assert _versions(library, video["id"]) == [] + assert library.get(f"/api/assets/{video['id']}").json()["data"]["missing"] is False + + +def test_deleting_the_video_deletes_its_versions(library, session, media_dir): + video = _fixture_video(library) + paths = [ + media_dir / _fake_version(session, media_dir, video["id"], size).storage_key + for size in (720, 480) + ] + + assert library.delete(f"/api/assets/{video['id']}").status_code == 204 + + assert session.exec(select(AssetVersion)).all() == [] + assert not any(path.exists() for path in paths) + + +# ─── making one the original ─────────────────────────────────────────────────── + + +def test_make_original_swaps_the_file_and_deletes_the_big_one(library, session, media_dir): + video = _fixture_video(library) + library.post(f"/api/assets/{video['id']}/tags", json={"names": ["harbour"]}) + clip = library.post( + f"/api/assets/{video['id']}/clips", json={"in_point": 0.2, "out_point": 1.0} + ).json()["data"] + old_path = media_dir / session.get(Asset, video["id"]).storage_key + small = _fake_version(session, media_dir, video["id"], 720, size=500) + other = _fake_version(session, media_dir, video["id"], 480, size=300) + small_key = small.storage_key + + response = library.post(f"/api/assets/{video['id']}/versions/{small.id}/make-original") + + assert response.status_code == 200, response.text + asset = response.json()["data"] + assert asset["id"] == video["id"] + assert (asset["width"], asset["height"], asset["size_bytes"]) == (1280, 720, 500) + assert [t["name"] for t in asset["tags"]] == ["harbour"] + assert session.get(Asset, video["id"]).storage_key == small_key + assert not old_path.exists() + assert (media_dir / small_key).exists() + # It is the original now, not a version; the other version stays. + assert [v["id"] for v in _versions(library, video["id"])] == [other.id] + # A clip plays the new file, over the same timeline. + clip_row = library.get(f"/api/assets/{clip['id']}").json()["data"] + assert small_key in clip_row["file_url"] + + +def test_make_original_waits_for_a_version_being_made(library, session, media_dir): + video = _fixture_video(library) + _pretend_size(session, video["id"], 3840, 2160) + version = _fake_version(session, media_dir, video["id"], 1080) + library.post(f"/api/assets/{video['id']}/versions", json={"short_side": 720}) + + response = library.post(f"/api/assets/{video['id']}/versions/{version.id}/make-original") + assert response.status_code == 409 + + +def test_another_videos_version_is_a_404(library, session, media_dir): + one = _fixture_video(library) + two = _fixture_video(library) + version = _fake_version(session, media_dir, one["id"], 720) + response = library.post(f"/api/assets/{two['id']}/versions/{version.id}/make-original") + assert response.status_code == 404 + + +# ─── file counts and sizes ───────────────────────────────────────────────────── + + +def test_versions_count_in_the_library_total(library, session, media_dir): + video = _fixture_video(library) + before = library.get("/api/storage").json()["data"]["library"] + _fake_version(session, media_dir, video["id"], 720, size=500) + _fake_version(session, media_dir, video["id"], 480, size=300) + + after = library.get("/api/storage").json()["data"]["library"] + + assert after["versions"] == {"files": 2, "bytes": 800} + assert after["files"] == before["files"] + 2 + assert after["total_bytes"] == before["total_bytes"] + 800 + # The originals' own rows are unchanged: versions are their own group. + assert after["by_type"] == before["by_type"] + + +def test_largest_ranks_by_original_plus_versions(library, session, media_dir): + def video_of(size: int) -> Asset: + row = Asset( + user_id=TEST_USER, name=f"v{size}", asset_type="video", + storage_key=f"{TEST_USER}/{uuid.uuid4()}.mp4", size_bytes=size, + ) + session.add(row) + session.commit() + return row + + big = video_of(1000) + small_with_versions = video_of(800) + _fake_version(session, media_dir, small_with_versions.id, 720, size=400) + + largest = library.get("/api/storage").json()["data"]["library"]["largest"] + + assert [row["id"] for row in largest[:2]] == [small_with_versions.id, big.id] + assert largest[0]["size_bytes"] == 800 + assert largest[0]["versions_bytes"] == 400 + + +def test_a_version_file_is_not_a_stray(library, session, media_dir, monkeypatch): + monkeypatch.setattr(settings, "admin_users", "tester") + video = _fixture_video(library) + version = _fake_version(session, media_dir, video["id"], 720) + path = media_dir / version.storage_key + past = time.time() - 3 * 24 * 3600 + os.utime(path, (past, past)) + + library.post("/api/storage/cleanup") + + assert path.exists() diff --git a/docs/plan-of-attack.md b/docs/plan-of-attack.md index fe3fa6e..b62f04a 100644 --- a/docs/plan-of-attack.md +++ b/docs/plan-of-attack.md @@ -349,6 +349,18 @@ CSP's Notes origin follow `NOTES_BASE_URL`. rework — full-bleed intrinsic grid, the resizable docked panel, `DetailDock`/`Tabs`, and `/a/{id}` as a page. Also non-milestone: M1 built that UI for one screen size and M3–M6 accreted panels onto it without revisiting it. +Video versions are also non-milestone: an asset's Versions tab, where its standard sizes +(4K down to 360p) are made by hand, one at a time, as files of that one asset in the +`assetversion` table rather than as assets of their own. YouTube's model, and a +replacement for [#55](https://github.com/davior/gam/pull/55), which made each size a +separate asset with a copy of the original's metadata. The size is the frame's *short +side*, because Video Creator renders 9:16 and 1:1 as well as 16:9, and only sizes below +the original are offered. "Make original" swaps a version's file in as the asset's own and +deletes the big one: how space comes back without losing tags, transcript or clips, which +all survive because the timeline is unchanged. Versions count in the storage panel as their +own group, in a file's size under "Largest files", and as referenced in the stray sweep. +**Open:** Video Creator does not pick a version yet. `GET /api/assets/{id}/versions` is +what it would read to take the 1080p for a 1080p project. ### Outstanding, unscheduled diff --git a/frontend/src/api/storage.ts b/frontend/src/api/storage.ts index c93e887..ea8369c 100644 --- a/frontend/src/api/storage.ts +++ b/frontend/src/api/storage.ts @@ -32,13 +32,19 @@ export interface LargestAsset { id: string name: string asset_type: string + /** The original's bytes alone. */ size_bytes: number + /** What its versions add. Ranked by the two together: a delete frees both. */ + versions_bytes: number } export interface LibraryUsage { total_bytes: number files: number by_type: TypeUsage[] + /** Standard-resolution versions (1080p, 720p, ...) of videos, apart from the + * originals they were made from. */ + versions: FileGroup thumbnails: FileGroup largest: LargestAsset[] } diff --git a/frontend/src/api/versions.ts b/frontend/src/api/versions.ts new file mode 100644 index 0000000..a5c29f5 --- /dev/null +++ b/frontend/src/api/versions.ts @@ -0,0 +1,69 @@ +import client from '@/api/client' +import type { Asset } from '@/api/assets' +import type { ActivityJob } from '@/api/transcripts' + +/** + * A video's standard-resolution versions, over `routers/versions.py`: the 1080p, the + * 720p, beside the original. Files of the one asset, not assets of their own. + */ + +export interface AssetVersion { + id: string + asset_id: string + /** The standard size it was made as: the frame's short side in pixels. */ + short_side: number + width: number | null + height: number | null + size_bytes: number + codec: string | null + mime_type: string | null + file_url: string | null + created_at: string +} + +/** The frame's short side in pixels: 1080 is 1920×1080 landscape or 1080×1920 + * portrait. Mirrors `enrichment/versions.py::SIZES`. */ +export const VERSION_SIZES = [2160, 1440, 1080, 720, 480, 360] as const + +export function versionLabel(shortSide: number): string { + return shortSide === 2160 ? '4K' : `${shortSide}p` +} + +interface DataResponse { + data: T +} + +interface ListResponse { + data: T[] + total: number +} + +export const versionsApi = { + /** Largest first. */ + list(assetId: string): Promise { + return client + .get>(`/assets/${assetId}/versions`) + .then((r) => r.data.data) + }, + + /** Queues one size. Only one is made at a time per video. */ + create(assetId: string, shortSide: number): Promise { + return client + .post>(`/assets/${assetId}/versions`, { + short_side: shortSide, + }) + .then((r) => r.data.data) + }, + + remove(assetId: string, versionId: string): Promise { + return client.delete(`/assets/${assetId}/versions/${versionId}`).then(() => undefined) + }, + + /** The version's file becomes the asset's own and the original is deleted. + * Irreversible. Resolves with the asset as it now is. */ + makeOriginal(assetId: string, versionId: string): Promise { + return client + .post>(`/assets/${assetId}/versions/${versionId}/make-original`) + .then((r) => r.data.data) + }, +} diff --git a/frontend/src/components/ActivityIndicator.tsx b/frontend/src/components/ActivityIndicator.tsx index 201c0d2..da36266 100644 --- a/frontend/src/components/ActivityIndicator.tsx +++ b/frontend/src/components/ActivityIndicator.tsx @@ -20,6 +20,7 @@ const LABELS: Record = { autotag: 'Suggesting tags', extract_text: 'Reading text', import_url: 'Importing', + make_version: 'Making a version', import_track: 'Importing a track', generate: 'Generating', harvest_attribution: 'Reading file metadata', diff --git a/frontend/src/components/AssetDetail.tsx b/frontend/src/components/AssetDetail.tsx index cfb4d82..8fc5636 100644 --- a/frontend/src/components/AssetDetail.tsx +++ b/frontend/src/components/AssetDetail.tsx @@ -3,6 +3,7 @@ import { Eye, FileText, Info, + Layers, Link2, Mic, Music, @@ -46,6 +47,7 @@ import SuggestionPanel from '@/components/SuggestionPanel' import TagInput from '@/components/TagInput' import Tabs, { type TabSpec } from '@/components/Tabs' import TranscriptPanel from '@/components/TranscriptPanel' +import VersionsTab from '@/components/VersionsTab' import WaveformPlayer from '@/components/WaveformPlayer' /** A clip has no bytes, no transcript and no AI enrichment of its own — a promoted or @@ -699,6 +701,18 @@ export default function AssetDetail({ }, ] : []), + // The original and its standard sizes. Not on a clip, which has no file of its own + // to make sizes of: its parent's Versions tab is where they are. + ...(asset.asset_type === 'video' && !ownsNoFile(asset) + ? [ + { + id: 'versions', + label: 'Versions', + icon: Layers, + content: , + }, + ] + : []), ...(asset.asset_type === TEXT_TYPE ? [ { diff --git a/frontend/src/components/StoragePanel.test.tsx b/frontend/src/components/StoragePanel.test.tsx index d241350..8516df1 100644 --- a/frontend/src/components/StoragePanel.test.tsx +++ b/frontend/src/components/StoragePanel.test.tsx @@ -46,10 +46,23 @@ function summary(overrides: Partial = {}): StorageSummary { { asset_type: 'video', files: 34, bytes: 120 * GB }, { asset_type: 'image', files: 6, bytes: 9 * GB }, ], + versions: { files: 0, bytes: 0 }, thumbnails: { files: 40, bytes: 1 * GB }, largest: [ - { id: 'a1', name: 'Giordano interview', asset_type: 'video', size_bytes: 8 * GB }, - { id: 'a2', name: 'Harbour at dusk', asset_type: 'image', size_bytes: 2 * GB }, + { + id: 'a1', + name: 'Giordano interview', + asset_type: 'video', + size_bytes: 8 * GB, + versions_bytes: 0, + }, + { + id: 'a2', + name: 'Harbour at dusk', + asset_type: 'image', + size_bytes: 2 * GB, + versions_bytes: 0, + }, ], }, server: null, @@ -91,6 +104,39 @@ describe('StoragePanel', () => { ) }) + it('counts video versions apart from the originals, and in a file’s size', async () => { + const base = summary().library + vi.mocked(storageApi.summary).mockResolvedValue( + summary({ + library: { + ...base, + versions: { files: 3, bytes: 2 * GB }, + largest: [ + { + id: 'a1', + name: 'Giordano interview', + asset_type: 'video', + size_bytes: 8 * GB, + versions_bytes: 1 * GB, + }, + ], + }, + }) + ) + renderPanel() + + expect(await screen.findByText('Video versions')).toBeInTheDocument() + expect(screen.getByText('3 files · 2.0 GB')).toBeInTheDocument() + expect(screen.getByText('9.0 GB')).toBeInTheDocument() + expect(screen.getByText(/incl\. 1\.0 GB in versions/)).toBeInTheDocument() + }) + + it('has no versions row when there are none', async () => { + renderPanel() + await screen.findByText('130.0 GB') + expect(screen.queryByText('Video versions')).not.toBeInTheDocument() + }) + it('shows a non-admin no server numbers and no sweep', async () => { renderPanel() @@ -218,6 +264,7 @@ describe('StoragePanel', () => { total_bytes: 0, files: 0, by_type: [], + versions: { files: 0, bytes: 0 }, thumbnails: { files: 0, bytes: 0 }, largest: [], }, diff --git a/frontend/src/components/StoragePanel.tsx b/frontend/src/components/StoragePanel.tsx index 3378507..45c6b75 100644 --- a/frontend/src/components/StoragePanel.tsx +++ b/frontend/src/components/StoragePanel.tsx @@ -403,6 +403,15 @@ export default function StoragePanel() { ))} + {library.versions.files > 0 && ( +
+
Video versions
+
+ {plural(library.versions.files, 'file')} ·{' '} + {formatBytes(library.versions.bytes)} +
+
+ )} {library.thumbnails.files > 0 && (
Previews
@@ -434,8 +443,11 @@ export default function StoragePanel() { {TYPE_LABELS[asset.asset_type] ?? asset.asset_type} ·{' '} - {formatBytes(asset.size_bytes)} + {formatBytes(asset.size_bytes + asset.versions_bytes)} + {asset.versions_bytes > 0 && ( + <> (incl. {formatBytes(asset.versions_bytes)} in versions) + )} ))} diff --git a/frontend/src/components/VersionsTab.test.tsx b/frontend/src/components/VersionsTab.test.tsx new file mode 100644 index 0000000..7332ddd --- /dev/null +++ b/frontend/src/components/VersionsTab.test.tsx @@ -0,0 +1,157 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import VersionsTab from '@/components/VersionsTab' +import { versionsApi, type AssetVersion } from '@/api/versions' +import { activityApi, type ActivityJob } from '@/api/transcripts' +import type { Asset } from '@/api/assets' +import { useActivityStore } from '@/stores/activity' +import { useLibraryStore } from '@/stores/library' +import { noAttribution } from '@/test-fixtures' + +const MB = 1024 * 1024 + +function makeVideo(overrides: Partial = {}): Asset { + return { + id: 'a1', + name: 'Harbour', + description: null, + summary: null, + asset_type: 'video', + source: 'local_upload', + parent_asset_id: null, + in_point: null, + out_point: null, + original_name: null, + mime_type: null, + file_format: null, + size_bytes: 900 * MB, + duration_seconds: 60, + width: 1920, + height: 1080, + codec: null, + file_url: null, + thumb_url: null, + missing: false, + tags: [], + ...noAttribution, + upload_date: '2026-01-01T00:00:00', + modified_date: '2026-01-01T00:00:00', + metadata_modified_date: '2026-01-01T00:00:00', + ...overrides, + } +} + +const v720: AssetVersion = { + id: 'v720', + asset_id: 'a1', + short_side: 720, + width: 1280, + height: 720, + size_bytes: 200 * MB, + codec: 'h264', + mime_type: 'video/mp4', + file_url: '/media/u/v720.mp4?exp=1&sig=x', + created_at: '2026-01-01T00:00:00', +} + +function renderTab(asset: Asset = makeVideo()) { + return render( + + + + ) +} + +beforeEach(() => { + useActivityStore.setState({ jobs: [] }) + useLibraryStore.getState().reset() + vi.spyOn(activityApi, 'list').mockResolvedValue([]) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('VersionsTab', () => { + it('lists the original and each smaller size, made or not', async () => { + vi.spyOn(versionsApi, 'list').mockResolvedValue([v720]) + renderTab() + + expect(await screen.findByText('Original')).toBeInTheDocument() + expect(screen.getByText('1920 × 1080 · 900.0 MB')).toBeInTheDocument() + // 1080p is the original's own size, so not on offer; 720p exists. + expect(screen.queryByText('1080p')).not.toBeInTheDocument() + expect(screen.getByText('1280 × 720 · 200.0 MB')).toBeInTheDocument() + // 480p and 360p are not made yet. + expect(screen.getAllByRole('button', { name: 'Create' })).toHaveLength(2) + expect(screen.getByText('1 version · 200.0 MB')).toBeInTheDocument() + }) + + it('starts the size whose Create was pressed', async () => { + vi.spyOn(versionsApi, 'list').mockResolvedValue([]) + const create = vi + .spyOn(versionsApi, 'create') + .mockResolvedValue({ id: 'j1' } as ActivityJob) + renderTab() + + const buttons = await screen.findAllByRole('button', { name: 'Create' }) + // 720p, 480p, 360p, in that order. + await userEvent.click(buttons[1]) + + await waitFor(() => expect(create).toHaveBeenCalledWith('a1', 480)) + }) + + it('asks before making a version the original', async () => { + vi.spyOn(versionsApi, 'list').mockResolvedValue([v720]) + const makeOriginal = vi + .spyOn(versionsApi, 'makeOriginal') + .mockResolvedValue(makeVideo({ width: 1280, height: 720 })) + vi.spyOn(useLibraryStore.getState(), 'refreshAsset').mockResolvedValue() + renderTab() + + await userEvent.click(await screen.findByRole('button', { name: 'Make original' })) + expect(makeOriginal).not.toHaveBeenCalled() + expect(screen.getByText(/cannot be brought back/)).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Replace original' })) + await waitFor(() => expect(makeOriginal).toHaveBeenCalledWith('a1', 'v720')) + }) + + it('asks before deleting a version', async () => { + vi.spyOn(versionsApi, 'list').mockResolvedValue([v720]) + const remove = vi.spyOn(versionsApi, 'remove').mockResolvedValue() + renderTab() + + await userEvent.click( + await screen.findByRole('button', { name: 'Delete the 720p version' }) + ) + await userEvent.click(screen.getByRole('button', { name: 'Delete version' })) + + await waitFor(() => expect(remove).toHaveBeenCalledWith('a1', 'v720')) + await waitFor(() => + expect(screen.queryByText('1280 × 720 · 200.0 MB')).not.toBeInTheDocument() + ) + }) + + it('offers nothing new while a version is being made', async () => { + vi.spyOn(versionsApi, 'list').mockResolvedValue([]) + useActivityStore.setState({ + jobs: [ + { + id: 'j1', + action: 'make_version', + asset_id: 'a1', + status: 'processing', + stage: 'Encoding', + progress: 40, + } as ActivityJob, + ], + }) + renderTab() + + const buttons = await screen.findAllByRole('button', { name: 'Create' }) + expect(buttons.every((b) => (b as HTMLButtonElement).disabled)).toBe(true) + }) +}) diff --git a/frontend/src/components/VersionsTab.tsx b/frontend/src/components/VersionsTab.tsx new file mode 100644 index 0000000..5108cc7 --- /dev/null +++ b/frontend/src/components/VersionsTab.tsx @@ -0,0 +1,294 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Download, Loader2, Plus, Trash2 } from 'lucide-react' +import type { Asset } from '@/api/assets' +import { apiErrorMessage } from '@/api/client' +import { + VERSION_SIZES, + type AssetVersion, + versionLabel, + versionsApi, +} from '@/api/versions' +import { isActive, useActivityStore } from '@/stores/activity' +import { useLibraryStore } from '@/stores/library' +import { formatBytes, formatDimensions } from '@/utils/format' + +/** + * A video's standard sizes: the original, and a row per smaller size, made or not. + * + * YouTube's model. Each size is made by hand from here, one at a time (the server + * allows one per video), and each is a file of this asset, not an asset of its own. + * "Make original" is how space comes back: that size's file replaces the original, + * which is deleted, while the asset (tags, transcript, clips, links) stays as it is. + */ + +interface Props { + asset: Asset +} + +type Confirming = { id: string; action: 'delete' | 'original' } | null + +export default function VersionsTab({ asset }: Props) { + const [versions, setVersions] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + // Which size this panel asked for, to show the progress on its row. A job started + // elsewhere (or before a reload) still disables every Create, just without a row. + const [requested, setRequested] = useState(null) + const [confirming, setConfirming] = useState(null) + const [busyId, setBusyId] = useState(null) + const job = useActivityStore((s) => + s.jobs.find((j) => j.asset_id === asset.id && j.action === 'make_version') + ) + const refreshActivity = useActivityStore((s) => s.refresh) + const refreshAsset = useLibraryStore((s) => s.refreshAsset) + const running = job ? isActive(job) : false + const currentId = useRef(asset.id) + + const load = useCallback(async () => { + const assetId = asset.id + try { + const rows = await versionsApi.list(assetId) + // Another asset may have opened in this panel while the request was out. + if (assetId !== currentId.current) return + setVersions(rows) + setError(null) + } catch (err) { + if (assetId !== currentId.current) return + setError(apiErrorMessage(err, 'Could not load versions')) + } finally { + if (assetId === currentId.current) setLoading(false) + } + }, [asset.id]) + + useEffect(() => { + currentId.current = asset.id + setLoading(true) + setVersions([]) + setRequested(null) + setConfirming(null) + void load() + }, [asset.id, load]) + + // The finished job wrote a row server-side; read the list again on that edge. + const wasRunning = useRef(false) + useEffect(() => { + if (wasRunning.current && !running) { + setRequested(null) + void load() + } + wasRunning.current = running + }, [running, load]) + + const create = async (shortSide: number) => { + setError(null) + setRequested(shortSide) + try { + await versionsApi.create(asset.id, shortSide) + await refreshActivity() + } catch (err) { + setRequested(null) + setError(apiErrorMessage(err, 'Could not start making that version')) + } + } + + const remove = async (version: AssetVersion) => { + setBusyId(version.id) + setError(null) + try { + await versionsApi.remove(asset.id, version.id) + setVersions((rows) => rows.filter((row) => row.id !== version.id)) + setConfirming(null) + } catch (err) { + setError(apiErrorMessage(err, 'Could not delete that version')) + } finally { + setBusyId(null) + } + } + + const makeOriginal = async (version: AssetVersion) => { + setBusyId(version.id) + setError(null) + try { + await versionsApi.makeOriginal(asset.id, version.id) + setConfirming(null) + // The asset's file, size and dimensions changed; the player and Info tab read + // them from the store. + await refreshAsset(asset.id) + await load() + } catch (err) { + setError(apiErrorMessage(err, 'Could not make that the original')) + } finally { + setBusyId(null) + } + } + + if (loading) { + return ( +

Loading versions…

+ ) + } + + const shortSide = + asset.width && asset.height ? Math.min(asset.width, asset.height) : null + const bySize = new Map(versions.map((v) => [v.short_side, v])) + // Every size below the original, plus any version that exists at or above it: one + // made before an earlier "Make original" swapped in a smaller file. + const sizes = VERSION_SIZES.filter( + (size) => shortSide === null || size < shortSide || bySize.has(size) + ) + const totalBytes = versions.reduce((sum, v) => sum + v.size_bytes, 0) + + return ( +
+
    +
  1. + + Original + + + {formatDimensions(asset.width, asset.height)} ·{' '} + {formatBytes(asset.size_bytes)} + +
  2. + + {sizes.map((size) => { + const version = bySize.get(size) + const isRequested = running && requested === size + + if (!version) { + return ( +
  3. + + {versionLabel(size)} + + {isRequested ? ( + + + {job?.stage || 'Queued'} + {job && job.progress > 1 ? ` · ${job.progress}%` : ''} + + ) : ( + <> + + Not made + + + + )} +
  4. + ) + } + + const asking = confirming?.id === version.id ? confirming.action : null + const busy = busyId === version.id + return ( +
  5. +
    + + {versionLabel(size)} + + + {formatDimensions(version.width, version.height)} ·{' '} + {formatBytes(version.size_bytes)} + + {version.file_url && ( + + + + )} + + +
    + + {asking && ( +
    +

    + {asking === 'original' + ? `Replace the original with the ${versionLabel(size)} file? The original (${formatBytes(asset.size_bytes)}) is deleted and cannot be brought back. Tags, transcript and clips stay.` + : `Delete the ${versionLabel(size)} version (${formatBytes(version.size_bytes)})? The original stays.`} +

    +
    + + +
    +
    + )} +
  6. + ) + })} +
+ + {sizes.length === 0 && ( +

+ This video is already at the smallest standard size. +

+ )} + +
+ {versions.length > 0 && ( +

+ {versions.length === 1 ? '1 version' : `${versions.length} versions`} ·{' '} + {formatBytes(totalBytes)} +

+ )} + {!running && job?.status === 'error' && job.error_message && ( +

{job.error_message}

+ )} + {error &&

{error}

} +
+
+ ) +} diff --git a/frontend/src/views/SettingsView.test.tsx b/frontend/src/views/SettingsView.test.tsx index 3bd5322..bf4d6e3 100644 --- a/frontend/src/views/SettingsView.test.tsx +++ b/frontend/src/views/SettingsView.test.tsx @@ -78,6 +78,7 @@ beforeEach(() => { total_bytes: 0, files: 0, by_type: [], + versions: { files: 0, bytes: 0 }, thumbnails: { files: 0, bytes: 0 }, largest: [], },