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
47 changes: 47 additions & 0 deletions backend/alembic/versions/20261016_0600_add_asset_origin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""add asset origin: the GVC project and render a gvc_export came from

GAM-3 (gvc's docs/plan-of-attack.md). Two nullable columns, set only on uploads that
Video Creator files back into the library with `source = "gvc_export"`:

- `origin_project_id`: the GVC project. Indexed, because "this project's renders" is
the one question asked of it.
- `origin_reference_id`: the GVC render job that made the file.

No foreign keys: both are ids in another application's database.

Adding columns and an index is plain ALTER on SQLite, so the upgrade does not recreate
`asset`. The downgrade's drops do, hence the PRAGMA dance `a8e1c0f4d2b6` explains.

Revision ID: 5b9e3d71c2a4
Revises: a8e1c0f4d2b6
Create Date: 2026-10-16 06:00:00.000000+00:00
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


revision: str = '5b9e3d71c2a4'
down_revision: Union[str, None] = 'a8e1c0f4d2b6'
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('origin_project_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('origin_reference_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.create_index('ix_asset_origin_project_id', ['origin_project_id'], unique=False)


def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute('PRAGMA foreign_keys=OFF')
with op.batch_alter_table('asset', schema=None) as batch_op:
batch_op.drop_index('ix_asset_origin_project_id')
batch_op.drop_column('origin_reference_id')
batch_op.drop_column('origin_project_id')
with op.get_context().autocommit_block():
op.execute('PRAGMA foreign_keys=ON')
9 changes: 9 additions & 0 deletions backend/app/models/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,15 @@ class Asset(SQLModel, table=True):
ai_seed: Optional[int] = None
ai_generated_at: Optional[datetime] = None

# ─── origin in another suite app (GAM-3) ─────────────────────────────────
# Set only on `source == "gvc_export"`: the Video Creator project a render came from
# and the render job that made it. Ids in another app's database, so no foreign key
# can hold them, and neither is ever followed from here; they exist so GVC can find
# "this project's renders" in one indexed lookup, and so a person looking at the
# file can tell which project to open to change it.
origin_project_id: Optional[str] = Field(default=None, index=True)
origin_reference_id: Optional[str] = None

# ─── provenance of the metadata, not the file ───────────────────────────
# JSON, {"description": "ai"|"human"|"embedded", ...}. FR 8.1.3 requires that a later AI run
# never silently overwrites something a person wrote; without recording who last
Expand Down
28 changes: 26 additions & 2 deletions backend/app/routers/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
from datetime import datetime
from typing import AsyncIterator, List, Optional

from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status
from sqlmodel import Session, col, func, select

from app.auth import CurrentUser
from app.database import get_session
from app.ingest.filetypes import ASSET_TYPES
from app.ingest.filetypes import ASSET_TYPES, SOURCE_GVC, SOURCE_UPLOAD
from app.jobs import enrichment as enrichment_jobs
from app.jobs.registry import KINDS
from app.models.asset import Asset
Expand Down Expand Up @@ -64,6 +64,9 @@ def _owned(asset_id: str, user_id: str, session: Session) -> Asset:
async def upload_assets(
user: CurrentUser,
files: List[UploadFile] = File(...),
source: Optional[str] = Form(default=None, max_length=50),
origin_project_id: Optional[str] = Form(default=None, max_length=64),
origin_reference_id: Optional[str] = Form(default=None, max_length=64),
session: Session = Depends(get_session),
storage: LocalStorage = Depends(get_storage),
) -> UploadResult:
Expand All @@ -73,7 +76,25 @@ async def upload_assets(
unsupported stores nineteen and reports the twentieth, rather than refusing all of
them. A user who dragged in a folder should not have to find the offending file
themselves.

`source` and the `origin_*` ids are GAM-3: how Video Creator files a finished render
back into the library. `gvc_export` is the only source a client may claim. The
others (`ai_generated`, `clip`, `url`…) are statements GAM makes about files it
produced itself, and a client able to set them could pass an upload off as, say, a
generation the regenerate button then tries to rerun.
"""
if source is not None and source not in (SOURCE_GVC, SOURCE_UPLOAD):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "invalid_source", "message": f"An upload cannot claim the source {source!r}"},
)
if (origin_project_id or origin_reference_id) and source != SOURCE_GVC:
# An origin only means something with the app that set it; on an ordinary
# upload it would be an id nobody can look up.
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "origin_without_source", "message": "An origin needs source=gvc_export"},
)
created: list[AssetRead] = []
rejected: list[UploadRejection] = []

Expand All @@ -100,6 +121,9 @@ async def upload_assets(
filename=filename,
chunks=_stream(upload),
content_type=upload.content_type,
source=source or SOURCE_UPLOAD,
origin_project_id=origin_project_id or None,
origin_reference_id=origin_reference_id or None,
)
created.append(service.to_read_model(asset, storage))
except service.UnsupportedFile:
Expand Down
4 changes: 4 additions & 0 deletions backend/app/schemas_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ class AssetRead(BaseModel):
# M8. How this asset was made, when fal.ai made it; null for everything else.
generation: Optional["AssetGenerationRead"] = None

# GAM-3. The GVC project and render a `gvc_export` came from; null otherwise.
origin_project_id: Optional[str] = None
origin_reference_id: Optional[str] = None

upload_date: datetime
modified_date: datetime
metadata_modified_date: datetime
Expand Down
10 changes: 10 additions & 0 deletions backend/app/services/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ async def ingest_upload(
chunks: AsyncIterator[bytes],
content_type: Optional[str] = None,
source: str = SOURCE_UPLOAD,
origin_project_id: Optional[str] = None,
origin_reference_id: Optional[str] = None,
) -> Asset:
"""Store the bytes, then describe them.

Expand All @@ -92,6 +94,8 @@ async def ingest_upload(
asset_type=asset_type,
source=source,
content_type=content_type,
origin_project_id=origin_project_id,
origin_reference_id=origin_reference_id,
)


Expand Down Expand Up @@ -150,6 +154,8 @@ def _catalogue(
source: str,
content_type: Optional[str],
name: Optional[str] = None,
origin_project_id: Optional[str] = None,
origin_reference_id: Optional[str] = None,
) -> Asset:
"""Everything that happens once the bytes are safely stored."""
extension = extension_of(filename)
Expand All @@ -164,6 +170,8 @@ def _catalogue(
file_format=extension.lstrip(".") or None,
size_bytes=stored.size_bytes,
checksum_sha256=stored.sha256,
origin_project_id=origin_project_id,
origin_reference_id=origin_reference_id,
)
session.add(asset)
session.commit()
Expand Down Expand Up @@ -899,6 +907,8 @@ def to_read_model(
for t in (asset_tags or [])
],
generation=generation_of(asset),
origin_project_id=asset.origin_project_id,
origin_reference_id=asset.origin_reference_id,
upload_date=asset.upload_date,
modified_date=asset.modified_date,
metadata_modified_date=asset.metadata_modified_date,
Expand Down
47 changes: 47 additions & 0 deletions backend/tests/test_assets_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,53 @@ def _upload(client, *names: str):
# ─── upload ──────────────────────────────────────────────────────────────────


def _export(library, **fields):
data = (FIXTURES / "sample_video.mp4").read_bytes()
return library.post(
"/api/assets",
files=[("files", ("render.mp4", data, "video/mp4"))],
data=fields,
)


def test_a_video_creator_render_is_filed_with_its_origin(library):
# GAM-3: GVC filing a finished render back into the library.
response = _export(
library, source="gvc_export", origin_project_id="proj-1", origin_reference_id="render-9"
)
assert response.status_code == 201, response.text
asset = response.json()["created"][0]
assert asset["source"] == "gvc_export"
assert asset["origin_project_id"] == "proj-1"
assert asset["origin_reference_id"] == "render-9"

# Read back the same, and found by the "Video Creator" filter.
listed = library.get("/api/assets", params={"source": "gvc_export"}).json()["data"]
assert [a["id"] for a in listed] == [asset["id"]]
assert library.get(f"/api/assets/{asset['id']}").json()["data"]["origin_project_id"] == "proj-1"


def test_an_ordinary_upload_has_no_origin(library):
asset = _upload(library, "sample_image.jpg").json()["created"][0]
assert asset["source"] == "local_upload"
assert asset["origin_project_id"] is None
assert asset["origin_reference_id"] is None


@pytest.mark.parametrize("source", ["ai_generated", "clip", "url", "sub_video", "nonsense"])
def test_an_upload_cannot_claim_a_source_gam_reserves(library, source):
response = _export(library, source=source)
assert response.status_code == 400
assert response.json()["detail"]["code"] == "invalid_source"
assert library.get("/api/assets").json()["total"] == 0


def test_an_origin_needs_the_gvc_source(library):
response = _export(library, origin_project_id="proj-1")
assert response.status_code == 400
assert response.json()["detail"]["code"] == "origin_without_source"


def test_upload_stores_and_describes_an_image(library):
response = _upload(library, "sample_image.jpg")
assert response.status_code == 201
Expand Down
4 changes: 3 additions & 1 deletion docs/plan-of-attack.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,9 @@ from a decision, which is the distinction PR bodies do not preserve.
Specified, with its decisions, operating notes and known limitations, in
[`url-import.md`](url-import.md). `SOURCE_URL` now has its writer, and since M8 so
does `SOURCE_AI` — `FilterBar`'s "AI generated" filter finally matches something.
`SOURCE_GVC` is still legitimately waiting on M9.
`SOURCE_GVC` has its writer since GAM-3: `POST /api/assets` takes `source=gvc_export`
(the only source a client may claim) with `origin_project_id` / `origin_reference_id`,
which is how Video Creator files a finished render back into the library.
- **~~`extract_text` was specified and never built.~~** Built. PDF, `.docx`, `.pptx`,
`.xlsx`, `.txt`/`.md`/`.csv`, one `DocumentPage` row per natural unit, and a
`FROM_DOCUMENT` branch in `enrichment/source.py` placed **above** the poster branch —
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export interface Asset {
summary: string | null
asset_type: AssetType
source: string
/** GAM-3. The Video Creator project and render a `gvc_export` came from. */
origin_project_id?: string | null
origin_reference_id?: string | null

/** M7. Provenance on any of clip/promoted/extracted; only a live clip
* (`source === 'clip'`) has no file of its own and needs `in_point`/`out_point`
Expand Down
Loading