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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ There are three ways to start one:
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.
- trimmed clips, and narration read through Gecko Notes;
- text, lower thirds and pictures on screen, timed to the scenes and lines they belong to;
- end credits from the library's attribution, and a description with chapters and credits.

Timed overlays, saving renders to GAM and credits are next (M2b).
A finished render can be saved back into GAM. AI drafting and the full storyboard
editor are next (M3, M4).

## Read first

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Add render_job.description and render_job.gam_asset_id (M2b).

`description`: the chapters and credits a render worked out, to paste where the video
is published. `gam_asset_id`: the GAM asset a render was saved as, so saving twice
files it once. An id in another app's database, so no foreign key.

Revision ID: 7e2d5a91b3c6
Revises: 1c0c7b02acd3
Create Date: 2026-10-16 07:00:00.000000+00:00
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


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


def upgrade() -> None:
with op.batch_alter_table('render_job', schema=None) as batch_op:
batch_op.add_column(sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('gam_asset_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))


def downgrade() -> None:
with op.batch_alter_table('render_job', schema=None) as batch_op:
batch_op.drop_column('gam_asset_id')
batch_op.drop_column('description')
22 changes: 20 additions & 2 deletions backend/app/gam/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,20 @@ def download(self, file_url: str, sink, *, max_bytes: int) -> int:
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."""
def upload(self, filename: str, stream: IO[bytes], content_type: Optional[str], *,
fields: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None) -> GamAssetRead:
"""Store one file in the user's library and return it.

`fields` go along as form fields: a render's `source=gvc_export` and origin ids
(GAM-3). A GAM without GAM-3 ignores them and stores an ordinary upload.
"""
response = self._request(
"POST",
"/api/assets",
files={"files": (filename, stream, content_type or "application/octet-stream")},
data=fields or None,
**({"timeout": timeout} if timeout else {}),
)
if response.status_code not in (200, 201):
code, message = self._detail(response)
Expand All @@ -172,3 +180,13 @@ def upload(self, filename: str, stream: IO[bytes], content_type: Optional[str])
raise GamError(rejected.get("code") or "gam_error",
rejected.get("message") or "The asset library did not store the file")
return to_asset(created[0])

def update(self, asset_id: str, changes: Dict[str, Any]) -> Optional[GamAssetRead]:
"""Edit an asset's fields (its name, its description). None: it is gone."""
response = self._request("PATCH", f"/api/assets/{asset_id}", json=changes)
if response.status_code == 404:
return None
if response.status_code != 200:
code, message = self._detail(response)
raise GamError(code, message)
return to_asset(response.json()["data"])
4 changes: 4 additions & 0 deletions backend/app/models/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,9 @@ class RenderJob(SQLModel, table=True):
duration: Optional[float] = None
size_bytes: Optional[int] = None
narration_chars: int = 0
# Chapters and credits, to paste where the video is published (M2b).
description: Optional[str] = None
# The GAM asset this render was saved as, once it has been (GAM-3).
gam_asset_id: Optional[str] = None
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)
101 changes: 95 additions & 6 deletions backend/app/render/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
from app.render.ffmpeg import KENBURNS_CYCLE, MIN_TRANSITION_SECONDS, XFADE_MAX_SHOTS
from app.render.options import XFADE_NAMES, is_crossfade
from app.spec.model import (
AnchorRef, AudioTrack, Beat, Card, CodeOverlay, Motion, QuoteOverlay, Segment,
Transition, VideoSpec,
AnchorRef, AudioTrack, Beat, Card, CodeOverlay, ImageOverlay, LowerThirdOverlay, Motion,
QuoteOverlay, Segment, TextOverlay, Transition, VideoSpec,
)

UnitKind = str # "still" | "card" | "video_muted" | "video_sound"
Expand Down Expand Up @@ -78,6 +78,8 @@ class Unit:
speed: float = 1.0
chrome: bool = True
chapter: Optional[str] = None
# The end credits: a card drawn from these lines rather than from `card`.
credits: Optional[List[str]] = None


def _transition_in(spec: VideoSpec, segment: Segment) -> Transition:
Expand Down Expand Up @@ -111,10 +113,8 @@ def _panel_beats(segment: Segment, warnings: List[str]) -> Tuple[Dict[str, Panel
whole: Optional[Panel] = None
for overlay in segment.overlays:
if not isinstance(overlay, (QuoteOverlay, CodeOverlay)):
# Timed text, lower thirds and images are M2b: a second technique
# (overlay with enable=) on top of this one. Said, never silently lost.
warnings.append(f"Scene {segment.label or segment.id}: {overlay.type} overlays "
"are not rendered yet")
# Timed text, lower thirds and images are drawn over the stitched video
# instead (`place_overlays`), since they may run across a join.
continue
anchor = overlay.show.from_ if overlay.show else None
if isinstance(anchor, AnchorRef) and anchor.beat and anchor.segment in (None, segment.id):
Expand Down Expand Up @@ -206,6 +206,31 @@ def plan_units(spec: VideoSpec, media: Dict[str, Media], warnings: List[str]) ->
return units


CREDITS_SEGMENT = "__credits__"
CREDIT_LINES_PER_CARD = 18


def credits_units(spec: VideoSpec, lines: Sequence[str]) -> List[Unit]:
"""The end credits as card units after everything else, one per screenful.

Not a segment in the spec: the lines come from GAM at render time (each asset's
credit), so the spec can't hold them, and a segment the user never made would
show up in the editor. Past a screenful they run over several cards rather than
shrinking to unreadable.
"""
if not spec.credits or not spec.credits.card or not lines:
return []
pages = [list(lines[i:i + CREDIT_LINES_PER_CARD])
for i in range(0, len(lines), CREDIT_LINES_PER_CARD)]
return [
Unit(segment_id=CREDITS_SEGMENT, segment_index=len(spec.segments),
first_in_segment=position == 0, kind="card", media=None, credits=page,
transition_in=spec.style.transition, min_seconds=spec.style.cards.seconds,
chrome=False)
for position, page in enumerate(pages)
]


def unit_duration(unit: Unit, narration_seconds: float, cards_seconds: float) -> float:
"""How long a unit runs, once its narration has been measured."""
if unit.kind == "video_sound":
Expand Down Expand Up @@ -429,3 +454,67 @@ def beat_pause_ms(spec: VideoSpec, beats: Sequence[Beat], position: int) -> int:
if beat.kind == "heading" or beats[position + 1].kind == "heading":
return pauses.heading_ms
return pauses.paragraph_ms


# ── timed overlays ──────────────────────────────────────────────────────────

TimedKind = Union[TextOverlay, LowerThirdOverlay, ImageOverlay]

DEFAULT_OVERLAY_FADE = 0.3
MIN_OVERLAY_SECONDS = 0.2


@dataclass
class OverlayPlacement:
segment_id: str
overlay: TimedKind
start: float
end: float
fade: float
media: Optional[Media] = None # an image overlay's picture


def place_overlays(spec: VideoSpec, media: Dict[str, Media], where: Placement,
warnings: List[str]) -> List[OverlayPlacement]:
"""Text, lower thirds and images as windows on the finished timeline.

With no `show`, an overlay covers its scene. Its anchors default to its own scene,
so `{"edge": "start", "offset": 0.5}` means half a second into it. As with audio
tracks, one that can't be placed is dropped with a warning (§5.7): the render
still goes ahead.
"""
placed: List[OverlayPlacement] = []
for segment in spec.segments:
span = where.segments.get(segment.id)
name = segment.label or segment.id
for overlay in segment.overlays:
if not isinstance(overlay, (TextOverlay, LowerThirdOverlay, ImageOverlay)):
continue
if span is None:
# The scene itself was left out (a clip with nothing to play).
continue
if overlay.show is None:
start, end = span
else:
try:
start = resolve_anchor(overlay.show.from_, where, segment=segment.id)
end = resolve_anchor(overlay.show.to, where, segment=segment.id)
except Orphaned as exc:
warnings.append(f"Scene {name}: an overlay is anchored to {exc}, which is "
"not in the video; left out")
continue
if end - start < MIN_OVERLAY_SECONDS:
warnings.append(f"Scene {name}: an overlay ends before it starts; left out")
continue
picture = None
if isinstance(overlay, ImageOverlay):
picture = media.get(overlay.asset)
if picture is None or picture.kind not in ("image", "slate"):
warnings.append(f"Scene {name}: an image overlay needs a picture; left out")
continue
fade = overlay.fade if overlay.fade is not None else DEFAULT_OVERLAY_FADE
# Never more than half the window each way, or the fades would cross.
fade = max(0.0, min(fade, (end - start) / 2))
placed.append(OverlayPlacement(segment_id=segment.id, overlay=overlay, start=start,
end=end, fade=fade, media=picture))
return placed
138 changes: 132 additions & 6 deletions backend/app/render/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,14 @@ def _anchor_xy(position: str, width: int, height: int, box_w: int, box_h: int, m
return (width - box_w - margin, height - box_h - margin)


def _card_base(width: int, height: int, background: Optional[Image.Image]) -> Image.Image:
base = (background.copy() if background is not None
else gradient_background(width, height, ["#1e293b", "#0f172a"], 135)).convert("RGB")
# Blur and darken whatever is behind so the text always has contrast.
base = base.filter(ImageFilter.GaussianBlur(radius=max(6, width // 90)))
return Image.blend(base, Image.new("RGB", (width, height), (0, 0, 0)), 0.45)


def card_image(
width: int, height: int, *,
title: str, subtitle: str = "",
Expand All @@ -207,12 +215,7 @@ def card_image(
one setting holds at every resolution and aspect ratio.
"""
spec = sizes or CardTextSpec()
base = (background.copy() if background is not None
else gradient_background(width, height, ["#1e293b", "#0f172a"], 135)).convert("RGB")

# Blur and darken whatever is behind so the text always has contrast.
base = base.filter(ImageFilter.GaussianBlur(radius=max(6, width // 90)))
base = Image.blend(base, Image.new("RGB", (width, height), (0, 0, 0)), 0.45)
base = _card_base(width, height, background)

layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
Expand Down Expand Up @@ -551,3 +554,126 @@ def _budget(margin: int, taken: int = 0) -> int:
layer.alpha_composite(mark, (max(0, x), max(0, y)))

return layer


# ── timed overlays (GVC) ────────────────────────────────────────────────────
# Each is a full-frame RGBA layer, like `overlay_layer`, so the video pass that shows
# it for its window is one plain `overlay=0:0` per layer with nothing to position.

OVERLAY_MARGIN_PCT = 5.0


def text_layer(width: int, height: int, *, text: str, position: str = "center",
size_pct: Optional[float] = None, color: Optional[str] = None) -> Image.Image:
"""On-screen text, wrapped, with a soft shadow so it reads over any picture."""
layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
margin = int(min(width, height) * OVERLAY_MARGIN_PCT / 100)
max_width = width - 2 * margin if position == "center" else int(width * 0.6)
start = max(8, int(height * (size_pct or 6.0) / 100))
font = fit_font(draw, text, SEMIBOLD, start, max_width)
lines = wrap_text(draw, text, font, max_width)[:8]
line_h = int(font.size * 1.25)
box_w = int(max((draw.textlength(line, font=font) for line in lines), default=0))
x, y = _anchor_xy(position, width, height, box_w, line_h * len(lines), margin)
colour = parse_colour(color or "#ffffff")

shadow = Image.new("RGBA", (width, height), (0, 0, 0, 0))
shadow_draw = ImageDraw.Draw(shadow)
for position_in_block, line in enumerate(lines):
lw = draw.textlength(line, font=font)
lx = x + (box_w - lw) / 2 if position == "center" else (
x + box_w - lw if position.endswith("right") else x)
ly = y + position_in_block * line_h
shadow_draw.text((lx, ly + max(1, font.size // 16)), line, font=font, fill=(0, 0, 0, 190))
draw.text((lx, ly), line, font=font, fill=(*colour, 255))
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=max(2, font.size // 10)))
return Image.alpha_composite(shadow, layer)


def lower_third_layer(width: int, height: int, *, title: str,
subtitle: str = "") -> Image.Image:
"""A name and a role, bottom left, on a translucent bar with an accent rule.

The bar is sized to its text rather than the frame, so a short name doesn't sit
in a long empty strip. Its colours match the title cards' rule.
"""
layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
margin = int(min(width, height) * OVERLAY_MARGIN_PCT / 100)
max_text = int(width * 0.7)
title_font = fit_font(draw, title, SEMIBOLD, max(10, int(height * 0.052)), max_text)
sub_font = load_font(REGULAR, max(8, int(height * 0.034)))
title = truncate_to_width(draw, title.strip(), title_font, max_text)
subtitle = truncate_to_width(draw, (subtitle or "").strip(), sub_font, max_text)

pad_x, pad_y = int(title_font.size * 0.6), int(title_font.size * 0.4)
rule_w = max(3, height // 160)
text_w = int(max(draw.textlength(title, font=title_font),
draw.textlength(subtitle, font=sub_font) if subtitle else 0))
title_h = int(title_font.size * 1.2)
sub_h = int(sub_font.size * 1.3) if subtitle else 0
box_w = rule_w + pad_x * 2 + text_w
box_h = pad_y * 2 + title_h + sub_h
x0 = margin
y0 = height - margin - box_h - int(height * 0.04)

draw.rectangle([x0, y0, x0 + box_w, y0 + box_h], fill=(15, 23, 42, 190))
draw.rectangle([x0, y0, x0 + rule_w, y0 + box_h], fill=(129, 140, 248, 255))
tx = x0 + rule_w + pad_x
draw.text((tx, y0 + pad_y), title, font=title_font, fill=(255, 255, 255, 255))
if subtitle:
draw.text((tx, y0 + pad_y + title_h), subtitle, font=sub_font, fill=(203, 213, 225, 255))
return layer


def image_layer(width: int, height: int, *, picture: str, position: str = "center",
scale_pct: Optional[float] = None,
opacity: Optional[float] = None) -> Optional[Image.Image]:
"""A picture over the video: a logo, a map, an inset. `scale_pct` is its width as
a share of the frame's; its aspect is kept."""
try:
with Image.open(picture) as raw:
image = ImageOps.exif_transpose(raw).convert("RGBA")
except (UnidentifiedImageError, OSError, ValueError) as exc:
logger.warning("Could not load overlay picture %s: %s", picture, exc)
return None
margin = int(min(width, height) * OVERLAY_MARGIN_PCT / 100)
target_w = max(1, int(width * (scale_pct or 30.0) / 100))
target_h = max(1, int(image.height * target_w / max(1, image.width)))
# Never taller than the frame allows, whatever the width asked for.
if target_h > height - 2 * margin:
target_h = max(1, height - 2 * margin)
target_w = max(1, int(image.width * target_h / max(1, image.height)))
image = image.resize((target_w, target_h), Image.LANCZOS)
if opacity is not None and opacity < 1:
alpha = image.getchannel("A").point(lambda a: int(a * max(0.0, opacity)))
image.putalpha(alpha)
layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
layer.alpha_composite(image, _anchor_xy(position, width, height, target_w, target_h, margin))
return layer


def credits_card(width: int, height: int, *, lines: Sequence[str],
background: Optional[Image.Image] = None) -> Image.Image:
"""The end credits: a heading over centred lines, smaller as there are more."""
base = _card_base(width, height, background)
layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
max_width = int(width * 0.82)
heading = load_font(SEMIBOLD, max(10, int(height * 0.06)))
# Sized so the whole page fits: a three-line page reads large, eighteen still fit.
size = max(8, min(int(height * 0.042), int(height * 0.7 / max(1, len(lines)) / 1.35)))
body = load_font(REGULAR, size)
wrapped = [truncate_to_width(draw, line, body, max_width) for line in lines]
head_h = int(heading.size * 1.6)
line_h = int(body.size * 1.35)
y = (height - head_h - line_h * len(wrapped)) // 2
w = draw.textlength("Credits", font=heading)
draw.text(((width - w) / 2, y), "Credits", font=heading, fill=(255, 255, 255, 255))
y += head_h
for line in wrapped:
w = draw.textlength(line, font=body)
draw.text(((width - w) / 2, y), line, font=body, fill=(226, 232, 240, 240))
y += line_h
return Image.alpha_composite(base.convert("RGBA"), layer).convert("RGB")
Loading
Loading