reel

package
v0.11.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 12, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package reel holds the deterministic core of the reel pipeline: the storyboard schema and validation, the VO-driven timing maths, orphan-controlled text wrapping, and accent-word parsing. It is a faithful port of the blog gen-reel.py and is pure (no I/O, no ffmpeg, no fonts) so it unit-tests directly (spec 0001 §3.1, §8). The card rendering and ffmpeg assembly live in the renderer adapter (a later phase).

Index

Constants

View Source
const (
	// ScrimUp ramps to full opacity at the BOTTOM — the default, and what every
	// pre-0050 card does.
	ScrimUp = "up"
	// ScrimDown ramps to full opacity at the TOP.
	ScrimDown = "down"
)

Scrim directions.

View Source
const (
	MediaImage MediaKind = "image"
	MediaVideo MediaKind = "video"

	SourceGenerated MediaSource = "generated"
	SourceUploaded  MediaSource = "uploaded"
	// SourceCover reuses the reel's cover art as the card's overlay panel (path
	// "cover.png", workspace-relative) — the natural fill for opening/closing
	// cards, usable on any card. Theme staleness is tracked on the cover itself,
	// not per card.
	SourceCover MediaSource = "cover"
)
View Source
const (
	Width  = 1080
	Height = 1920
	FPS    = 30
)

Geometry / timing defaults (parity with gen-reel.py).

View Source
const DefaultAspect = "9:16"

DefaultAspect is the reel's default frame shape (spec 0001 §4 — the 1080×1920 vertical). A workspace may override it per reel (workspace.Meta.Aspect, 0029 §9 note 27); the empty string always means this default, so existing reels render pixel-identically.

Variables

This section is empty.

Functions

func AccentWords

func AccentWords(text string) map[string]bool

AccentWords returns the set of words marked for the accent colour. A word is accented when its token carries a '*' marker. Faithful to gen-reel.py: in a multi-word emphasis like "*no one safer*" only the first and last tokens carry a marker, so the middle word is not accented (a known parity quirk).

func AspectDims added in v0.9.0

func AspectDims(aspect string) (w, h int, err error)

AspectDims resolves an "W:H" aspect keyword to output pixel dimensions. The short side is always 1080; the long side scales and rounds to even (codec requirement). "" resolves to the 9:16 default.

func CardDurationFromVO

func CardDurationFromVO(voSec float64, p Params) float64

CardDurationFromVO returns a card's on-screen duration given its narration clip length: VO + lead + tail (spec §3.1).

func CleanWord

func CleanWord(w string) string

CleanWord normalises a word for accent comparison: drop any '*' markers and surrounding punctuation.

func CountMarkers

func CountMarkers(text string) int

CountMarkers returns the number of '*' accent markers in the text (used by validation to require balanced markers).

func FormatIssues

func FormatIssues(errs []Issue) string

FormatIssues renders a list of issues as an indented, one-per-line block — the shared form the CLI build/make error messages use.

func Marshal

func Marshal(sb Storyboard) ([]byte, error)

Marshal serialises a storyboard to indented JSON (for drafts / round-trips).

func MediaIgnored added in v0.9.0

func MediaIgnored(c Card, themeDefault CardMode) bool

MediaIgnored reports whether a card carries an illustration its effective mode will not draw — a block card with media. That is incoherent rather than wrong, so callers warn (0044 D2): erroring would reject boards that only reached this state because picking an illustration never set the mode.

func Narration

func Narration(c Card) string

Narration is a card's spoken line: its explicit VO, else the accent-stripped on-screen text. The canonical definition shared by VO generation (the timing driver) and any UI that needs "what would this card say".

The fallback reads BLOCKS, not Text (spec 0038): a card may carry its words in texts[], and reading Text alone would yield an empty narration — no VO, and since VO drives duration, a card with no time on screen. Blocks are joined in render order, because they are one spoken line however they are laid out.

func StripAccents

func StripAccents(text string) string

StripAccents removes the *accent* markers, yielding the text as rendered.

func ValidScrimDirection added in v0.10.0

func ValidScrimDirection(d string) bool

ValidScrimDirection reports whether d is a direction the renderer understands. Empty is valid and means the default.

func Wrap

func Wrap(text string, maxWidth float64, measure Measurer) []string

Wrap breaks text into display lines no wider than maxWidth, preserving explicit paragraph breaks ("\n") and applying orphan control: never leave a lone trailing word — pull one word down from the previous line. Faithful port of gen-reel.py's wrap(). The caller is expected to have stripped accent markers (see StripAccents) before measuring.

Types

type Align added in v0.10.0

type Align string

Align is how a block's text sits inside its box — NOT where the box goes (D7). Changing it therefore never moves the block.

const (
	// AlignCentre centres each line in the box — the default, and what every
	// pre-0038 card does.
	AlignCentre Align = "centre"
	// AlignLeft ranges each line against the box's left edge.
	AlignLeft Align = "left"
	// AlignRight ranges each line against the box's right edge.
	AlignRight Align = "right"
)

type Anchor added in v0.10.0

type Anchor struct {
	X float64 `json:"x" mapstructure:"x" yaml:"x"`
	Y float64 `json:"y" mapstructure:"y" yaml:"y"`
}

Anchor is a position in the frame as a fraction of its width and height, with (0,0) top-left and (1,1) bottom-right. It is the block box's CENTRE on both axes (D5, D7) — so alignment never moves a block, it only moves the text inside it.

Frame fractions rather than pixels because a board must survive a change of output size: the same reel renders at 1080×1920 today and could render larger tomorrow, and a pixel anchor would drift. Fractions also make dragging in the studio the same arithmetic as rendering (D1).

type Card

type Card struct {
	// Text is omitted when empty so a card that carries its words in Texts does
	// not also write `"text": ""`, which reads like a field someone forgot to
	// fill in. Every pre-0038 board has a non-empty Text, so nothing existing
	// serialises differently.
	Text string `json:"text,omitempty" yaml:"text,omitempty"`

	// Lead / Tail override the theme's padding for this scene — the "air" before
	// and after its narration, in seconds (spec 0050 D4). An override REPLACES
	// rather than adds: lead 1.2 means 1.2s, not 1.2s on top of the theme's.
	//
	// Pointers, because zero is a real value: `lead: 0` means this beat starts
	// speaking immediately, which a plain float64 could not distinguish from
	// "unset".
	Lead *float64 `json:"lead,omitempty" yaml:"lead,omitempty"`
	Tail *float64 `json:"tail,omitempty" yaml:"tail,omitempty"`

	// Scrim overrides the theme's scrim for this card (spec 0050 D6) — for a
	// scene whose illustration fights the text. Absent means the theme's.
	Scrim *Scrim `json:"scrim,omitempty" yaml:"scrim,omitempty"`

	// Texts supersedes Text when present (spec 0038): several positioned blocks
	// on one scene. Absent means the single Text laid out by the mode default,
	// so every pre-0038 board renders identically — see Card.Blocks, which is the
	// only place that choice is made.
	Texts []TextBlock `json:"texts,omitempty" yaml:"texts,omitempty"`
	VO    string      `json:"vo,omitempty"   yaml:"vo,omitempty"`

	Bg     string `json:"bg,omitempty"     yaml:"bg,omitempty"`
	Fg     string `json:"fg,omitempty"     yaml:"fg,omitempty"`
	Accent string `json:"accent,omitempty" yaml:"accent,omitempty"`

	// Dur is the fallback on-screen duration (seconds) used only when no VO
	// drives timing.
	Dur float64 `json:"dur,omitempty" yaml:"dur,omitempty"`

	// Cover is RETIRED (spec 0050 D1) and renders nothing. The block-mode bookend
	// scaled the reel's cover art into a box above the text, throwing most of it
	// away; an overlay card whose media is the cover shows it full bleed instead.
	//
	// The field survives so `storyboard migrate` can still recognise and convert a
	// board written before that change. Removing it would make those boards
	// unmigratable — the flag would be dropped silently on the next save, with
	// nothing left for the migration to find. It is omitempty, so a migrated board
	// carries no trace of it.
	Cover bool `json:"cover,omitempty" yaml:"cover,omitempty"`
	Mono  bool `json:"mono,omitempty"  yaml:"mono,omitempty"`

	// Overlay-mode fields.
	Mode  CardMode `json:"mode,omitempty"  yaml:"mode,omitempty"`
	Scene string   `json:"scene,omitempty" yaml:"scene,omitempty"`
	Media *Media   `json:"media,omitempty" yaml:"media,omitempty"`

	// Avatars is the subset of the reel's cast that appears in this scene (spec 0034
	// D4) — a checklist, not a picker. Empty/absent = NOBODY: a scene with no ticks
	// is a scene with no one in it, and renders as plain text-to-image.
	Avatars []string `json:"avatars,omitempty" yaml:"avatars,omitempty"`

	// Voice is an optional per-card voice override.
	Voice *VoiceOverride `json:"voice,omitempty" yaml:"voice,omitempty"`
}

Card is one storyboard card. The on-screen `Text` (a tight distillation) and the `VO` narration are distinct inputs: VO may be fuller and carry provider control tags (SSML <break>, phonetic spellings). Palette-role applicability is mode-dependent (block uses Bg/Fg/Accent; overlay ignores Bg).

func (Card) Blocks added in v0.10.0

func (c Card) Blocks() []TextBlock

Blocks returns the card's text blocks in render order.

This is the compatibility seam, and the only place that decides it: a card with Texts uses them; a card without gets exactly one unpositioned block from Text, which the renderer lays out by its mode default. Every pre-0038 board therefore renders unchanged (D4).

func (Card) Timing added in v0.10.0

func (c Card) Timing(p Params) Params

Timing resolves this card's padding against the reel's params (spec 0050 D4): the card's own lead/tail when it declares them, else the theme's, else the parity defaults. An override replaces rather than adds.

type CardMode

type CardMode string

CardMode is a card's visual treatment.

const (
	// ModeBlock is one short line over a solid palette background.
	ModeBlock CardMode = "block"
	// ModeOverlay is a full-bleed media panel + bottom scrim + overlaid line.
	ModeOverlay CardMode = "overlay"
)

func RenderMode added in v0.9.0

func RenderMode(c Card, themeDefault CardMode) (mode CardMode, degraded bool)

RenderMode returns the mode a card actually renders in, and whether it was degraded for want of an illustration.

Precedence: the card's own mode wins, then the theme's default, then block. An overlay card with no media degrades to block rather than failing — that is the normal state of a board mid-authoring, and honouring a theme's overlay default without this would turn every un-illustrated card into a build error.

type Issue

type Issue struct {
	Card int
	Msg  string
}

Issue is a single validation finding tied to a card index (-1 = board-level).

func (Issue) String

func (i Issue) String() string

type Measurer

type Measurer func(string) float64

Measurer reports the rendered width of a string in a given font. The renderer supplies a font-metrics measurer; tests supply a deterministic one. Keeping width measurement injected is what makes wrapping pure and unit-testable.

type Media

type Media struct {
	Kind   MediaKind   `json:"kind"             yaml:"kind"`
	Source MediaSource `json:"source"           yaml:"source"`
	Path   string      `json:"path"             yaml:"path"`
	// Theme records the reel theme a *generated* illustration was made under, so the
	// editor can flag "this media predates a theme change — re-roll?" (R-UI-22).
	// Empty for uploaded media (theme-independent) and pre-existing storyboards.
	Theme string `json:"theme,omitempty"  yaml:"theme,omitempty"`
}

Media is a resolved card media panel (overlay mode).

type MediaKind

type MediaKind string

MediaKind / MediaSource describe a resolved card media panel.

type MediaSource

type MediaSource string

MediaKind / MediaSource describe a resolved card media panel.

type Migration added in v0.10.0

type Migration struct {
	// Name identifies the migration in output and in tests.
	Name string
	// Why is the one-line reason, shown to whoever runs it — a migration nobody
	// can explain is a migration nobody trusts.
	Why string
	// Apply rewrites one card in place, returning true if it changed anything.
	Apply func(*Card) bool
}

Migration is one named rewrite. Apply reports whether it changed the card.

func Migrations added in v0.10.0

func Migrations() []Migration

Migrations are every pending rewrite, in the order they must run.

type MigrationResult added in v0.10.0

type MigrationResult struct {
	// Name is the migration's.
	Name string
	// Cards are the 1-based indices it changed.
	Cards []int
}

MigrationResult records what a migration did to a board.

func Migrate added in v0.10.0

func Migrate(sb Storyboard) []MigrationResult

Migrate applies every pending migration to sb IN PLACE and reports what each one changed. A board already on the current schema comes back untouched with an empty result, so running it twice is safe.

func (MigrationResult) Changed added in v0.10.0

func (r MigrationResult) Changed() bool

Changed reports whether anything was rewritten.

type Params

type Params struct {
	XFade float64
	Lead  float64
	Tail  float64
}

Params are the timing knobs. Zero values are replaced by the parity defaults via WithDefaults, so the zero Params behaves like the Python script.

func DefaultParams

func DefaultParams() Params

DefaultParams returns the parity timing defaults.

func (Params) WithDefaults

func (p Params) WithDefaults() Params

WithDefaults fills any zero field from the parity defaults.

type Result

type Result struct {
	Errors   []Issue
	Warnings []Issue
}

Result is the outcome of validating a storyboard: MUST failures are Errors, SHOULD findings are Warnings. The same set backs the CLI exit 2, the API 422, and the studio inline validation (0002 §3.1).

func Validate

func Validate(sb Storyboard, palette map[string]bool) Result

Validate checks a storyboard's semantic rules (R-WS-9..13). palette is the set of defined palette role keys from the resolved theme; when empty, palette-role checks (R-WS-12) are skipped (no theme to check against). R-WS-14 (cover cards reference an available cover) is a render-time check, not a load-time one.

func (Result) OK

func (r Result) OK() bool

OK reports whether the storyboard passed (no errors).

type Scrim added in v0.10.0

type Scrim struct {
	From      *float64 `json:"from,omitempty"      mapstructure:"from"      yaml:"from,omitempty"`
	Color     string   `json:"color,omitempty"     mapstructure:"color"     yaml:"color,omitempty"`
	Opacity   *float64 `json:"opacity,omitempty"   mapstructure:"opacity"   yaml:"opacity,omitempty"`
	Direction string   `json:"direction,omitempty" mapstructure:"direction" yaml:"direction,omitempty"`
}

Scrim overrides part of the theme's scrim for one card. Every field is optional; an unset field keeps the theme's value.

The float fields are POINTERS because zero is a meaningful value for both — `from: 0` is a scrim over the whole frame, and `opacity: 0` turns it off. A plain float64 could not tell "off" from "unset".

type Storyboard

type Storyboard []Card

Storyboard is the ordered set of cards (the storyboard.json is a bare array, parity with gen-reel.py).

func Parse

func Parse(data []byte) (Storyboard, error)

Parse decodes a storyboard.json (a JSON array of cards), rejecting malformed JSON with an actionable error. Semantic validation is Validate's job.

type TextBlock added in v0.10.0

type TextBlock struct {
	Text string `json:"text" mapstructure:"text" yaml:"text"`

	// Pos anchors the block in the frame. Absent (nil) means the renderer's
	// mode-default placement, so a block can be added without positioning it.
	Pos *Anchor `json:"pos,omitempty" mapstructure:"pos" yaml:"pos,omitempty"`

	// Align is horizontal alignment about Pos; empty means centre.
	Align Align `json:"align,omitempty" mapstructure:"align" yaml:"align,omitempty"`

	// Size scales the theme's base text size — 1.4 is forty percent larger.
	// Zero means the theme's size unchanged.
	Size float64 `json:"size,omitempty" mapstructure:"size" yaml:"size,omitempty"`

	// Width is the block's box width as a fraction of the frame width. Zero means
	// the mode's default (the old margin-derived width).
	//
	// Explicit rather than derived from Pos (D7). Deriving it — the original D6 —
	// meant a block changed shape as it moved, so the thing under the cursor kept
	// re-wrapping while being dragged. A box you set and then place is both the
	// familiar model and a stable one.
	Width float64 `json:"width,omitempty" mapstructure:"width" yaml:"width,omitempty"`

	// Accent is the palette role for this block's *marked* words, overriding the
	// card's. A highlight belongs to the words it highlights (D13) — one scene can
	// carry a headline marked in amber and a footnote marked in cream.
	Accent string `json:"accent,omitempty" mapstructure:"accent" yaml:"accent,omitempty"`

	// Role names a PALETTE ROLE ("amber", "cream"), never a hex value (D2).
	// A colour written into a board would not follow a theme change, which is
	// the thing themes exist to make possible. Empty means the theme's
	// foreground.
	Role string `json:"role,omitempty" mapstructure:"role" yaml:"role,omitempty"`
}

TextBlock is one positioned run of text on a card.

func (TextBlock) AccentOr added in v0.10.0

func (b TextBlock) AccentOr(cardAccent string) string

AccentOr returns the block's accent role, falling back to the card's.

Resolution is theme → card → block: a card-level accent still applies to every block that declares none, so boards written before per-block accents render unchanged.

func (TextBlock) AlignOr added in v0.10.0

func (b TextBlock) AlignOr() Align

AlignOr returns the block's alignment, defaulting to centre.

func (TextBlock) Positioned added in v0.10.0

func (b TextBlock) Positioned() bool

Positioned reports whether a block carries an explicit anchor.

func (TextBlock) SizeOr added in v0.10.0

func (b TextBlock) SizeOr(base float64) float64

SizeOr returns the block's size scale, or base when it declares none.

func (TextBlock) WrapFraction added in v0.10.0

func (b TextBlock) WrapFraction(marginFrac float64) float64

WrapFraction is the block box's width as a fraction of the frame width (D7).

marginFrac is the mode's TOTAL side allowance, so a block that declares no width of its own gets the mode default and behaves exactly as it always has.

This is deliberately pure arithmetic with no font metrics in it, because it is the one part of layout the studio preview can reproduce EXACTLY. Everything else about the preview is an approximation (browser fonts, no ffmpeg); this is not, and layout-cases.json pins the Go and JS implementations to each other.

Superseded D6, which derived this from Pos. See D7 for why that had to go.

type Timeline

type Timeline struct {
	// Durations is the per-card on-screen duration.
	Durations []float64
	// Starts[i] is card i's start time on the assembled timeline (xfade-adjusted):
	// sum(durs[:i]) - i*xfade.
	Starts []float64
	// VODelaysMS[i] is the millisecond delay at which card i's VO is placed
	// (start + lead), clamped at zero.
	VODelaysMS []int
	// XFadeOffsets[k] is the ffmpeg xfade offset for the transition into card
	// k+1 (len = n-1); equal to Starts[k+1].
	XFadeOffsets []float64
	// Total is the finished reel length: sum(durs) - (n-1)*xfade.
	Total float64
}

Timeline is the computed reel timeline.

func BuildTimeline

func BuildTimeline(durs []float64, p Params, leads ...[]float64) Timeline

BuildTimeline computes the timeline from per-card on-screen durations. Faithful port of gen-reel.py's offset/starts/total maths.

leads, when non-empty, is each card's own lead — the delay from the card appearing to its narration starting (spec 0050 D4). A shorter or absent slice falls back to p.Lead, which is every pre-0050 board.

type VoiceOverride

type VoiceOverride struct {
	// Speaker selects a registered voice (config voices.<name>) as this line's
	// base voice, overriding the reel theme's voice (multi-author narration).
	Speaker    string  `json:"speaker,omitempty"    yaml:"speaker,omitempty"`
	Stability  float64 `json:"stability,omitempty"  yaml:"stability,omitempty"`
	Similarity float64 `json:"similarity,omitempty" yaml:"similarity,omitempty"`
	Style      float64 `json:"style,omitempty"      yaml:"style,omitempty"`
	Speed      float64 `json:"speed,omitempty"      yaml:"speed,omitempty"`
	// Model selects the TTS model for this line only (empty ⇒ theme/default). Pronounce
	// is a respelling used as this line's narration on any model; IPA is a phonetic
	// rendering used instead on a phoneme-capable model (ignored on multilingual_v2).
	// Spec 0022 H/I.
	Model     string `json:"model,omitempty"     yaml:"model,omitempty"`
	Pronounce string `json:"pronounce,omitempty" yaml:"pronounce,omitempty"`
	IPA       string `json:"ipa,omitempty"       yaml:"ipa,omitempty"`
}

VoiceOverride lets a single line tune its narration voice (e.g. steadying a wobbly line by bumping stability, or slowing a line that reads too fast), or select a different speaker for multi-author reels.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL