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
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""add clip columns to asset

M7's clip and sub-video extraction both need a parent pointer and a time range.
`parent_asset_id` carries a real foreign key, unlike `transcriptsegment`/
`documentpage`'s no-FK-plus-explicit-cleanup pattern: a live clip (`storage_key IS
NULL`) is meant to *block* its parent's deletion until it is promoted or removed, and
SQLite enforcing that is the safety net behind `services/assets.py::delete_asset`'s own
guard — the same belt-and-suspenders role the FK already plays for `assettag` and
`suggestion`. No prior migration adds a foreign key to a table that already exists (both
of those got theirs at `create_table` time); `create_foreign_key` inside batch mode is
what SQLite's "recreate the table" batch strategy needs to add one after the fact.

Revision ID: 7d4b9c1a6f28
Revises: 56ac14e89a0c
Create Date: 2026-09-18 09:00:00.000000+00:00
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


revision: str = '7d4b9c1a6f28'
down_revision: Union[str, None] = '56ac14e89a0c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
with op.batch_alter_table('asset', schema=None) as batch_op:
batch_op.add_column(sa.Column('parent_asset_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('in_point', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('out_point', sa.Float(), nullable=True))
batch_op.create_index('ix_asset_parent_asset_id', ['parent_asset_id'], unique=False)
batch_op.create_foreign_key('fk_asset_parent_asset_id_asset', 'asset', ['parent_asset_id'], ['id'])


def downgrade() -> None:
with op.batch_alter_table('asset', schema=None) as batch_op:
batch_op.drop_constraint('fk_asset_parent_asset_id_asset', type_='foreignkey')
batch_op.drop_index('ix_asset_parent_asset_id')
batch_op.drop_column('out_point')
batch_op.drop_column('in_point')
batch_op.drop_column('parent_asset_id')
122 changes: 122 additions & 0 deletions backend/app/enrichment/extract_subvideo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""The extract_subvideo job: a parent's bytes in, a standalone clip file out.

Two modes carried in the job's `payload` (see `models/job.py::KIND_EXTRACT_SUBVIDEO`):
`"extract"` cuts a fresh range straight from a file-owning asset, leaving it untouched;
`"promote"` does the same cut but writes the result back into an *existing* clip row
(one with no `storage_key` of its own) rather than creating a new one — the one-click
"make this reference a real file" path a blocked delete offers. Both go through the
same ffmpeg mechanics in `enrichment/subvideo.py` and the same Asset-row plumbing in
`services/assets.py`, which is where sub-video creation was always meant to land (see
that module's own docstring).

Runs on a worker thread, so it owns its own session and reports progress through the
callback the queue hands it — which is also the cancellation checkpoint.
"""

from __future__ import annotations

import json
import logging
import tempfile
from pathlib import Path
from typing import Callable, Optional

from sqlmodel import Session

from app.enrichment.subvideo import SubvideoExtractionError, extract_subvideo
from app.ingest import thumbnails
from app.ingest.filetypes import TYPE_AUDIO
from app.ingest.probe import probe
from app.models.asset import Asset
from app.services import assets as asset_service
from app.storage import build_storage, new_key, thumb_key_for

logger = logging.getLogger(__name__)

Progress = Callable[[str, int, str], None]


def run(
session: Session, asset: Asset, payload: Optional[str], progress: Progress
) -> tuple[str, Optional[str]]:
"""Run one extraction or promotion. Returns (detail, created_asset_id).

`created_asset_id` is only set for `"extract"` mode — the frontend has nowhere
else to learn the new asset's id, since the job's own `asset_id` stays pointed at
the *source*, not the row that does not exist until this returns. `"promote"`
updates the clip's own row in place, so the frontend already has that id: it is
`job.asset_id` throughout, and nothing new needs reporting.
"""
try:
data = json.loads(payload or "{}")
except ValueError:
data = {}
mode = data.get("mode")

if mode == "promote":
clip = asset
if clip.storage_key is not None or not clip.parent_asset_id:
raise SubvideoExtractionError("This asset is not a clip")
source_asset = session.get(Asset, clip.parent_asset_id)
if source_asset is None or not source_asset.storage_key:
raise SubvideoExtractionError("The original asset is no longer available")
in_point = clip.in_point or 0.0
out_point = clip.out_point or 0.0
else:
source_asset = asset
if not source_asset.storage_key:
raise SubvideoExtractionError("The source file is not available")
try:
in_point = float(data["in_point"])
out_point = float(data["out_point"])
except (KeyError, TypeError, ValueError) as exc:
raise SubvideoExtractionError("Missing or invalid in/out points") from exc

storage = build_storage()
progress("Extracting", 10, "")

# Always .mp4 (.m4a for an audio source): both ffmpeg strategies in subvideo.py
# either stream-copy into it or re-encode to h264/aac, which is always valid
# content for this container regardless of what the source's own format was —
# simpler than trying to match the source's extension, and the re-encode fallback
# already recovers automatically if a stream copy into it fails.
extension = ".m4a" if source_asset.asset_type == TYPE_AUDIO else ".mp4"

with storage.materialise(source_asset.storage_key) as source_path:
with tempfile.TemporaryDirectory(prefix="gam-subvideo-") as workdir:
target_path = Path(workdir) / f"subvideo{extension}"
extract_subvideo(source_path, target_path, in_point, out_point)

progress("Reading metadata", 60, "")
result = probe(target_path)
preview = thumbnails.generate(
target_path,
source_asset.asset_type,
source_asset.original_name or "",
duration_seconds=result.duration_seconds,
)

progress("Saving", 80, "")
stored = storage.write_file(new_key(source_asset.user_id, extension), target_path)
thumb_key = None
if preview:
thumb = storage.write_bytes(thumb_key_for(stored.key), preview)
thumb_key = thumb.key

detail = f"{out_point - in_point:.0f}s sub-video"

if mode == "promote":
asset_service.promote_clip(session, clip, stored=stored, thumb_key=thumb_key, probe_result=result)
return detail, None

created = asset_service.create_subvideo_asset(
session,
source_asset=source_asset,
stored=stored,
thumb_key=thumb_key,
in_point=in_point,
out_point=out_point,
name=data.get("name"),
probe_result=result,
)
return detail, created.id
9 changes: 9 additions & 0 deletions backend/app/enrichment/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ def gather(
a summary what it is *about*. The transcript stays primary either way; this adds to
it, and does not reorder the precedence the milestone doc sets out.
"""
if asset.storage_key is None and asset.parent_asset_id:
# A clip (M7). It has no transcript of its own — only the parent does, for the
# whole recording rather than this range — and it inherits the parent's
# `thumb_key` (see `services/assets.py::create_clip`), so without this check
# the poster-fallback branch below would silently "succeed" using a generic
# frame instead of ever raising. That produces a description of the wrong
# content rather than failing loudly, which is worse than refusing outright.
raise NoSourceMaterial("This is a clip. Describe, summarise or tag the original asset instead.")

text = transcript_text(session, asset)
if text:
truncated = len(text) > MAX_TRANSCRIPT_CHARS
Expand Down
133 changes: 133 additions & 0 deletions backend/app/enrichment/subvideo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Cutting a time range out of a video or audio file.

Two ffmpeg strategies, tried in order. The fast one seeks on the input side and
stream-copies — no re-encode, so it runs in roughly the time it takes to read the
bytes — but a stream copy can only start on a keyframe, so the seek lands at the
nearest one at or before `in_point`, not exactly on it. For most footage that is a
fraction of a second and nobody notices; for content with sparse keyframes (a screen
recording, a slideshow-style video) it can be seconds off, which is wrong enough to
matter for a "cut exactly this moment" feature. So the fast output is checked against
the requested duration, and only re-encoded — output-side seeking, frame-accurate,
slower — when it actually missed by enough to matter.
"""

from __future__ import annotations

import logging
import subprocess
from pathlib import Path

from app.ingest.probe import probe
from app.media_tools import ffmpeg_available

logger = logging.getLogger(__name__)

# How far the fast path's actual duration may overshoot the requested one before it is
# considered inaccurate enough to redo. Generous relative to a typical keyframe
# interval (2-10s on most encoders), stingy relative to what "cut at 01:01" promises.
DURATION_TOLERANCE_SECONDS = 0.5


class SubvideoExtractionError(Exception):
"""Something the user can be told about."""


# Same shape as `enrichment/audio.py::timeout_for`: scales with the length of the cut,
# not the source file, since that is what ffmpeg actually has to process either way.
def timeout_for(duration_seconds: float | None) -> int:
if not duration_seconds or duration_seconds <= 0:
return 300
return int(min(max(duration_seconds * 2, 120), 3600))


def extract_subvideo(source: Path, target: Path, in_point: float, out_point: float) -> Path:
"""Write `source`'s [in_point, out_point) range to `target`.

Takes absolute coordinates against a real (non-clip) source — resolving a clip's
own in/out against its parent is the caller's job, not this function's, and M7 does
not need it (clipping a clip is out of scope; see `services/assets.py::create_clip`).
"""
if not ffmpeg_available():
raise SubvideoExtractionError("ffmpeg is not available on this server")
if out_point <= in_point:
raise SubvideoExtractionError("The out point must be after the in point")

duration = out_point - in_point
target.parent.mkdir(parents=True, exist_ok=True)
timeout = timeout_for(duration)

if _fast_copy(source, target, in_point, duration, timeout):
actual = probe(target).duration_seconds
if actual is not None and actual <= duration + DURATION_TOLERANCE_SECONDS:
return target
logger.info(
"Stream-copy cut of %s overshot (wanted %.2fs, got %s) — re-encoding for accuracy",
source.name, duration, actual,
)

_reencode(source, target, in_point, out_point, timeout)
return target


def _fast_copy(source: Path, target: Path, in_point: float, duration: float, timeout: int) -> bool:
"""Input-side seek plus stream copy. Returns whether it produced a usable file."""
argv = [
"ffmpeg", "-nostdin",
# Before -i: ffmpeg seeks to the nearest keyframe rather than decoding and
# discarding everything up to it, which is the whole point of the fast path.
"-ss", f"{in_point:.3f}",
"-i", str(source),
"-t", f"{duration:.3f}",
"-c", "copy",
# A stream copy starting mid-file otherwise carries the original timestamps,
# which some players render as a long stall before playback begins.
"-avoid_negative_ts", "make_zero",
"-y", str(target),
]
try:
completed = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.info("Stream-copy cut of %s failed: %s", source.name, exc)
return False

if completed.returncode != 0:
logger.info(
"Stream-copy cut of %s failed: %s", source.name, (completed.stderr or "").strip()[-400:]
)
return False
return target.is_file() and target.stat().st_size > 0


def _reencode(source: Path, target: Path, in_point: float, out_point: float, timeout: int) -> None:
"""Output-side seek, frame-accurate, always re-encodes."""
argv = [
"ffmpeg", "-nostdin",
"-i", str(source),
# After -i: ffmpeg decodes from the start and discards up to the mark, which is
# what makes this land exactly on `in_point` rather than the nearest keyframe.
"-ss", f"{in_point:.3f}",
"-to", f"{out_point:.3f}",
"-c:v", "libx264",
"-preset", "veryfast",
"-c:a", "aac",
# Moves the MP4 index to the front of the file, so playback can start before
# the whole file has downloaded — the same reason every upload gets it free
# from most modern encoders, made explicit here since libx264 does not default
# to it.
"-movflags", "+faststart",
"-y", str(target),
]
try:
completed = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired as exc:
raise SubvideoExtractionError("Extracting the sub-video took too long") from exc
except OSError as exc:
raise SubvideoExtractionError(f"Could not run ffmpeg: {exc}") from exc

if completed.returncode != 0:
stderr = (completed.stderr or "").strip()
logger.warning("Sub-video extraction failed for %s: %s", source.name, stderr[-400:])
raise SubvideoExtractionError("Could not extract that range from this file")

if not target.is_file() or target.stat().st_size == 0:
raise SubvideoExtractionError("Could not extract that range from this file")
7 changes: 7 additions & 0 deletions backend/app/ingest/filetypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@
SOURCE_URL = "url"
SOURCE_AI = "ai_generated"
SOURCE_GVC = "gvc_export"
# M7. A clip owns no bytes of its own (`Asset.storage_key IS NULL`) — this is the
# frontend-facing signal for that fact, not the guard itself; `services/assets.py`'s
# delete guard checks `storage_key` directly, since that is what a delete actually
# depends on being true. Promoting a clip, or extracting one fresh from a parent,
# flips `source` to `SOURCE_SUBVIDEO` — a real, standalone file from here on.
SOURCE_CLIP = "clip"
SOURCE_SUBVIDEO = "sub_video"

# 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
Loading
Loading