From 7a69791d6e5d243b5b2b467f231a001cfeb88846 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:12:11 +0000 Subject: [PATCH] Re-encode a video to a smaller resolution as a new asset A 4K original is too heavy to work with in a 1080p or 720p Video Creator project. The Info tab gains "Smaller copy": pick 1440p/1080p/720p/480p/360p (only sizes below the source are offered, and the server refuses an upscale) and one ffmpeg run in the job queue writes an H.264/AAC MP4 as a new asset. - The target is the frame's short side, so a portrait 4K phone video becomes 1080x1920 rather than 608x1080: GVC renders 9:16 and 1:1 as well as 16:9. - ffmpeg's -progress output drives the job's percent. A long 4K encode would otherwise outlive the stale sweeper, and those reports are also what lets Cancel kill ffmpeg. - setsar=1: without it, rounding to an even width leaves non-square pixels (a 1002x750 source came out displaying 481 wide). - The copy carries the original's tags, description, summary, resolved attribution and transcript, so the original can be deleted for the space without losing any of it. parent_asset_id stays as provenance only. - A running re-encode blocks deleting its source, as an extraction does. 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, 868 insertions(+), 5 deletions(-) create mode 100644 backend/app/enrichment/reencode.py create mode 100644 backend/tests/test_reencode.py create mode 100644 frontend/src/components/ReencodePanel.test.tsx create mode 100644 frontend/src/components/ReencodePanel.tsx diff --git a/backend/app/enrichment/reencode.py b/backend/app/enrichment/reencode.py new file mode 100644 index 0000000..e6ce463 --- /dev/null +++ b/backend/app/enrichment/reencode.py @@ -0,0 +1,195 @@ +"""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 c978ced..fe0b326 100644 --- a/backend/app/ingest/filetypes.py +++ b/backend/app/ingest/filetypes.py @@ -51,6 +51,9 @@ # 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 38e620a..74c9fb1 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.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 @@ -70,6 +71,7 @@ KIND_DESCRIBE, KIND_EMBED, KIND_EXTRACT_SUBVIDEO, + KIND_REENCODE, KIND_EXTRACT_TEXT, GENERATION_JOB_KINDS, KIND_GENERATE, @@ -318,6 +320,10 @@ 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: @@ -452,6 +458,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 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 0dd8462..a636b67 100644 --- a/backend/app/jobs/registry.py +++ b/backend/app/jobs/registry.py @@ -26,6 +26,7 @@ KIND_GENERATE, KIND_IMPORT_TRACK, KIND_IMPORT_URL, + KIND_REENCODE, EnrichmentJob, ) from app.schemas_jobs import ActivityJobRead @@ -55,7 +56,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_IMPORT_URL, KIND_IMPORT_TRACK, KIND_GENERATE} + {KIND_EXTRACT_SUBVIDEO, KIND_REENCODE, KIND_IMPORT_URL, KIND_IMPORT_TRACK, KIND_GENERATE} ) diff --git a/backend/app/models/job.py b/backend/app/models/job.py index e9511ce..dcd9448 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" +# 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 ed076c0..681443b 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_REENCODE, ) 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 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 7e57adc..55ca424 100644 --- a/backend/app/routers/clips.py +++ b/backend/app/routers/clips.py @@ -1,4 +1,4 @@ -"""Clips (non-destructive) and sub-video extraction (destructive), over an asset.""" +"""Clips (non-destructive), and new files cut or re-encoded from an asset.""" from __future__ import annotations @@ -15,7 +15,8 @@ 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 +from app.enrichment.reencode import TARGETS, label_for, short_side_of +from app.models.job import KIND_EXTRACT_SUBVIDEO, KIND_REENCODE from app.routers.assets import get_storage from app.schemas import DataResponse, ListResponse from app.schemas_assets import AssetRead @@ -57,6 +58,14 @@ 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) @@ -230,3 +239,62 @@ 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 0afae44..4e706a6 100644 --- a/backend/app/services/assets.py +++ b/backend/app/services/assets.py @@ -25,6 +25,7 @@ from app.ingest.filetypes import ( SOURCE_AI, SOURCE_CLIP, + SOURCE_REENCODE, SOURCE_SUBVIDEO, SOURCE_UPLOAD, TYPE_AUDIO, @@ -44,6 +45,7 @@ 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 ( @@ -830,6 +832,108 @@ 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 new file mode 100644 index 0000000..b19fcf5 --- /dev/null +++ b/backend/tests/test_reencode.py @@ -0,0 +1,257 @@ +"""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 fe3fa6e..379c267 100644 --- a/docs/plan-of-attack.md +++ b/docs/plan-of-attack.md @@ -349,6 +349,13 @@ 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 10c3d47..bd193df 100644 --- a/frontend/src/api/clips.ts +++ b/frontend/src/api/clips.ts @@ -3,7 +3,8 @@ import type { Asset } from '@/api/assets' import type { ActivityJob } from '@/api/transcripts' /** - * Clips (non-destructive) and sub-video extraction (destructive), over `routers/clips.py`. + * Clips (non-destructive), and new files cut or re-encoded from an asset, 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` @@ -16,6 +17,14 @@ 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 } @@ -68,4 +77,15 @@ 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 201c0d2..f639c6c 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', + 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 cfb4d82..9098e1f 100644 --- a/frontend/src/components/AssetDetail.tsx +++ b/frontend/src/components/AssetDetail.tsx @@ -42,6 +42,7 @@ 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' @@ -515,6 +516,9 @@ 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 new file mode 100644 index 0000000..e56d40e --- /dev/null +++ b/frontend/src/components/ReencodePanel.test.tsx @@ -0,0 +1,90 @@ +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 new file mode 100644 index 0000000..a9695d6 --- /dev/null +++ b/frontend/src/components/ReencodePanel.tsx @@ -0,0 +1,94 @@ +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. +

+ )} +
+ ) +}