Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions backend/alembic/versions/20261029_1300_add_asset_versions.py
Original file line number Diff line number Diff line change
@@ -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')
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""The reencode job: a video's bytes in, a smaller copy of it out, as a new asset.
"""The make_version job: a video's original in, one standard-resolution file of it out.

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).
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 re-encodes for longer than the stale sweeper's
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.
Expand All @@ -25,30 +26,29 @@

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
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 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)
# 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 ReencodeError(Exception):
class VersionError(Exception):
"""Something the user can be told about."""


Expand All @@ -62,56 +62,38 @@ def short_side_of(asset: Asset) -> Optional[int]:
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)."""
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:
data = json.loads(payload or "{}")
short_side = int(data["short_side"])
short_side = int(json.loads(payload or "{}")["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")
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 ReencodeError("The source file is not available")
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 ReencodeError(capacity.DISK_FULL_MESSAGE)
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-reencode-") as workdir:
target_path = Path(workdir) / "reencode.mp4"
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("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, "")
result = probe(target_path)
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,

version_service.create(
session, asset=asset, short_side=short_side, stored=stored, probe_result=result
)
return f"{label} copy", created.id
return f"{label_for(short_side)} version"


def encode(
Expand All @@ -123,7 +105,7 @@ def encode(
) -> 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")
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
Expand All @@ -140,9 +122,10 @@ def encode(
"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
# 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
# copy that will be cut and re-rendered again downstream.
# 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
Expand All @@ -160,7 +143,7 @@ def encode(
argv, stdout=subprocess.PIPE, stderr=stderr_file, text=True
)
except OSError as exc:
raise ReencodeError(f"Could not run ffmpeg: {exc}") from exc
raise VersionError(f"Could not run ffmpeg: {exc}") from exc

try:
assert proc.stdout is not None
Expand All @@ -183,8 +166,8 @@ def encode(

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")
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:
Expand Down
3 changes: 0 additions & 3 deletions backend/app/ingest/filetypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 5 additions & 7 deletions backend/app/jobs/enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +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.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
Expand Down Expand Up @@ -71,7 +71,7 @@
KIND_DESCRIBE,
KIND_EMBED,
KIND_EXTRACT_SUBVIDEO,
KIND_REENCODE,
KIND_MAKE_VERSION,
KIND_EXTRACT_TEXT,
GENERATION_JOB_KINDS,
KIND_GENERATE,
Expand Down Expand Up @@ -320,10 +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_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_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:
Expand Down Expand Up @@ -458,7 +456,7 @@ 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:
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)
Expand Down
3 changes: 1 addition & 2 deletions backend/app/jobs/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
KIND_GENERATE,
KIND_IMPORT_TRACK,
KIND_IMPORT_URL,
KIND_REENCODE,
EnrichmentJob,
)
from app.schemas_jobs import ActivityJobRead
Expand Down Expand Up @@ -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}
)


Expand Down
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions backend/app/models/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +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"
# 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.
Expand Down
42 changes: 42 additions & 0 deletions backend/app/models/version.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading