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
17 changes: 16 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,29 @@ NOTES_BASE_URL=https://notes.geckopico.com
#
# In development: GAM_BASE_URL=http://localhost:8001 and NOTES_BASE_URL=http://localhost:8000.
# The dev user (DEV_AUTH_USER) sends no token, so GAM must run with a dev user too.
GAM_BASE_URL=https://gam.geckopico.com
GAM_BASE_URL=https://assets.geckopico.com

# Per-request timeout for GAM, in seconds.
GAM_TIMEOUT_SECONDS=30

# The largest file "Copy into GAM" will move out of a note pushed from Notes, in MB.
NOTE_MEDIA_MAX_MB=4096

# ─── Rendering ───────────────────────────────────────────────────────────────
#
# Narration is read through Gecko Notes (NOTES_BASE_URL) as the signed-in user, so
# voices and speech keys stay in Notes' settings. That needs a Notes with
# gecko-notes#211; an older one makes a render stop and say so.

# Renders running at once. Encoding is CPU-bound and shares the box with Notes and GAM.
RENDER_CONCURRENCY=1
RENDER_MAX_SEGMENTS=200
RENDER_MAX_NARRATION_CHARS=60000
# GAM files fetched for rendering are cached under data/cache/media, pruned to this.
RENDER_CACHE_MAX_GB=20
# How long the link to a finished render stays valid, in seconds.
MEDIA_URL_TTL_SECONDS=3600

# ─── Administrators ──────────────────────────────────────────────────────────

# Gecko Notes user ids or usernames, comma-separated, matched exactly. Nothing in GVC
Expand Down
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,16 @@ jobs:
cache: pip
cache-dependency-path: backend/requirements-dev.txt

# No ffmpeg step yet: nothing renders until M2, and no test shells out to it.
# Add GAM's "Ensure ffmpeg is available" step back with the renderer.
# The render tests encode real video. Conditional because the runner image may
# already ship it and `apt-get update` is not free.
- name: Ensure ffmpeg is available
run: |
if command -v ffmpeg >/dev/null && command -v ffprobe >/dev/null; then
echo "ffmpeg already present: $(ffmpeg -version | head -1)"
else
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg
fi

- name: Install dependencies
run: pip install -r requirements-dev.txt
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ venv/
.env.*
!.env.example

data/db/
data/
backend/data/
backend/*.db*
# Alembic's autogenerate scratch database (GAM committed one by accident once).
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,13 @@ There are three ways to start one:

## Status

**M0 (Genesis).** Sign in through Gecko Notes, create projects, build their structure
by hand or as JSON, validate them against the schema, keep a version history and import
a spec pushed from Notes. Nothing renders yet. That's M2.
**M2 (the renderer).** Build a video by hand, or push one in from Notes. Find footage in
GAM by what is said in it. Then preview and render it:
- per-scene transitions and Ken Burns moves between two frames of a picture;
- any number of soundtrack tracks, crossfading between scenes;
- trimmed clips, and narration read through Gecko Notes.

Timed overlays, saving renders to GAM and credits are next (M2b).

## Read first

Expand Down
56 changes: 56 additions & 0 deletions backend/alembic/versions/20261016_0500_add_render_job_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Add the render_job table: one render of one version of a project.

Revision ID: 1c0c7b02acd3
Revises: 389de295043a
Create Date: 2026-10-16 05:00:03.642906+00:00
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


revision: str = '1c0c7b02acd3'
down_revision: Union[str, None] = '389de295043a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table('render_job',
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('project_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('version_seq', sa.Integer(), nullable=False),
sa.Column('preview', sa.Boolean(), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('stage', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('progress', sa.Integer(), nullable=False),
sa.Column('detail', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('error_message', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('warnings', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('video_path', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('subtitle_path', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('poster_path', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('duration', sa.Float(), nullable=True),
sa.Column('size_bytes', sa.Integer(), nullable=True),
sa.Column('narration_chars', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('render_job', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_render_job_project_id'), ['project_id'], unique=False)
batch_op.create_index(batch_op.f('ix_render_job_user_id'), ['user_id'], unique=False)



def downgrade() -> None:
with op.batch_alter_table('render_job', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_render_job_user_id'))
batch_op.drop_index(batch_op.f('ix_render_job_project_id'))

op.drop_table('render_job')
Binary file added backend/app/assets/fonts/Inter-Regular.ttf
Binary file not shown.
Binary file added backend/app/assets/fonts/Inter-SemiBold.ttf
Binary file not shown.
92 changes: 92 additions & 0 deletions backend/app/assets/fonts/OFL.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL

-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
29 changes: 29 additions & 0 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@

import base64
import hashlib
import hmac
import logging
import time
from typing import Annotated, Optional
from urllib.parse import urlsplit

Expand Down Expand Up @@ -317,3 +319,30 @@ def decrypt_api_key(value: str) -> str:
except InvalidToken:
logger.warning("Stored key could not be decrypted; treating as unset")
return ""


# ─── signed media URLs ───────────────────────────────────────────────────────
#
# Copied from GAM. A finished render is served at a signed URL rather than behind the
# Authorization header, because a <video> element cannot send one. A signature works where an Authorization header cannot: a <video> or <img> element
# cannot send one, but it can carry query parameters.


def sign_media_key(key: str, *, ttl_seconds: Optional[int] = None) -> tuple[int, str]:
"""Return `(expiry, signature)` for a storage key."""
ttl = settings.media_url_ttl_seconds if ttl_seconds is None else ttl_seconds
expires_at = int(time.time()) + ttl
return expires_at, _media_signature(key, expires_at)


def _media_signature(key: str, expires_at: int) -> str:
message = f"{key}|{expires_at}".encode()
digest = hmac.new(settings.jwt_secret_key.encode(), message, hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=")


def verify_media_signature(key: str, expires_at: int, signature: str) -> bool:
"""Constant-time check of a media URL's signature and expiry."""
if expires_at < int(time.time()):
return False
return hmac.compare_digest(_media_signature(key, expires_at), signature)
26 changes: 25 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,15 @@ class Settings(BaseSettings):
# all there is to locate.
database_url: str = ""

# Where renders, the media cache and render work live. `data/db` stays the
# database's own directory inside it.
data_dir: str = ""
# How long a signed URL to a finished render stays valid.
media_url_ttl_seconds: int = 3600

# ─── siblings ────────────────────────────────────────────────────────────
# The media library: footage, pictures and music are searched for and kept there.
gam_base_url: str = "https://gam.geckopico.com"
gam_base_url: str = "https://assets.geckopico.com"
# Per request to GAM. Generous, because a search that runs its semantic half calls
# an embedding provider before it answers.
gam_timeout_seconds: float = 30
Expand All @@ -69,6 +75,20 @@ class Settings(BaseSettings):
# ─── http ────────────────────────────────────────────────────────────────
cors_origin: str = ""

# ─── rendering (M2) ───────────────────────────────────────────────────────
# One at a time by default: encoding is CPU-bound, and on the suite's server it
# shares the machine with Notes and GAM.
render_concurrency: int = 1
render_max_segments: int = 200
# Narration is billed per character, through the user's own speech provider.
render_max_narration_chars: int = 60_000
# Size the render cache (GAM files fetched for rendering) is pruned back to.
render_cache_max_gb: float = 20
# The largest single file a render will fetch from GAM.
render_max_file_mb: int = 8192
job_heartbeat_seconds: int = 30
job_stale_minutes: int = 40

# ─── administration ──────────────────────────────────────────────────────
# Comma-separated user ids or usernames, matched exactly. The Notes token carries no
# admin claim, so this is the only way anybody becomes one. Nothing in M0 is
Expand Down Expand Up @@ -99,6 +119,10 @@ def resolved_database_url(self) -> str:
return self.database_url
return f"sqlite:///{REPO_ROOT / 'data' / 'db' / 'gvc.db'}"

@property
def resolved_data_dir(self) -> Path:
return Path(self.data_dir) if self.data_dir else REPO_ROOT / "data"

@property
def cors_origins(self) -> List[str]:
return [o.strip() for o in self.cors_origin.split(",") if o.strip()]
Expand Down
18 changes: 18 additions & 0 deletions backend/app/gam/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def to_asset(raw: Dict[str, Any]) -> GamAssetRead:
file_url=_absolute(raw.get("file_url")),
thumb_url=_absolute(raw.get("thumb_url")),
credit=raw.get("credit") or "",
size_bytes=raw.get("size_bytes") or 0,
parent_asset_id=raw.get("parent_asset_id"),
in_point=raw.get("in_point"),
out_point=raw.get("out_point"),
Expand Down Expand Up @@ -136,6 +137,23 @@ def get_asset(self, asset_id: str) -> Optional[GamAssetRead]:
raise GamError(code, message)
return to_asset(response.json()["data"])

def download(self, file_url: str, sink, *, max_bytes: int) -> int:
"""Stream one of GAM's signed media URLs into `sink`. Returns the bytes written."""
written = 0
try:
with self._http.stream("GET", file_url, timeout=600) as response:
if response.status_code != 200:
raise GamError("gam_media_unavailable",
f"The asset library would not hand over a file ({response.status_code})")
for chunk in response.iter_bytes(1024 * 1024):
written += len(chunk)
if written > max_bytes:
raise GamError("gam_media_too_large", "A file in this video is too large to render")
sink.write(chunk)
except httpx.HTTPError as exc:
raise GamError("gam_unavailable", "Could not fetch a file from the asset library") from exc
return written

def upload(self, filename: str, stream: IO[bytes], content_type: Optional[str]) -> GamAssetRead:
"""Store one file in the user's library and return it."""
response = self._request(
Expand Down
1 change: 1 addition & 0 deletions backend/app/jobs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Background jobs: the queue and worker threads, and the kinds that run on them."""
Loading
Loading