# Render Engine ## Two Services ### VideoRenderService **Location:** `backend/src/shared/render-engine/video-render.service.ts` The **single source of truth** for description rendering. Responsible for fetching all required data: - Description blocks (from `blockOrder`) - Block overrides and variable values (from `VideoConfig`) - Team variables - Campaign blocks (active ones are appended regardless of `blockOrder`) - Team date format and timezone - Playlists (for `{video.playlists}` token) - Collaborators (for `{collab.*}` tokens) Then delegates computation to `RenderEngineService`. **Used by:** `youtube-sync.processor`, `render.processor`, `videos.service.ts renderDescription()` ### RenderEngineService **Location:** `backend/src/shared/render-engine/render-engine.service.ts` Pure computation. Takes `RenderInput`, returns `{ rendered: string, hash: string }`. **Used directly by:** `video-configs.service.ts renderPreview()`, `templates.service.ts renderPreview()` — these callers already have all data fetched. ## Block Rendering by Type | Block Type | Rendering Behavior | |---|---| | `STATIC` | Raw content output, zero token substitution | | `VARIABLE` | Resolves `{custom_var}`, `{video.*}`, `{collab.*}` tokens | | `CAMPAIGN` | Auto-appended at end when `campaign.startAt ≤ now ≤ campaign.endAt` | | `COLLABORATOR` | Cloned once per assigned collaborator, joined with `\n\n` (or `\n` if compact) | | `CONDITIONAL` | Evaluates `condition` JSON against video data | | `GLOBAL`, `REPEATABLE` | Deprecated; render as VARIABLE | ## Freetext Entries `blockOrder` may contain IDs prefixed `freetext:` (e.g. `freetext:abc123`). These have no corresponding `DescriptionBlock` row. Their content comes entirely from `blockOverrides[id].content`. The `compact` flag for freetext entries also comes from `blockOverrides[id].compact`. ## Token Resolution Resolution happens in three distinct passes per block, applied sequentially: 1. **Custom variable pass** — `resolveVariables(content, effectiveVars)` replaces `{custom_token}` placeholders. `effectiveVars` is built by merging team variables and video-level values: `{ ...teamVariables, ...variableValues }`. **Video-level values win on any name collision** — last-write-wins from the spread. System tokens (`video.*`, `collab.*`) are explicitly skipped here. 2. **Video token pass** — `resolveVideoVars()` replaces `{video.*}` tokens using the video's own fields. 3. **Collaborator token pass** — `resolveCollaborators()` or `expandCollaboratorBlock()` replaces `{collab.*}` tokens. **Effective priority (highest → lowest):** 1. System tokens (`{video.*}`, `{collab.*}`) — always win; cannot be shadowed by any variable name 2. Video-level variables (`VideoConfig.variableValues`) — override team variables on conflict 3. Team variables (`TeamVariable` table) — baseline for custom tokens Because system tokens are skipped in pass 1 and resolved in dedicated passes afterwards, naming a team or video variable `video.title` has no effect — it is silently ignored and the actual video title is substituted instead. `DescriptionBlock.variableDefinitions` declares which tokens a block expects but is never read by the render engine — it is metadata only (see Backlog). Any token in `SYSTEM_VARIABLE_TOKENS` set is **skipped** by `resolveVariables()` and handled by its dedicated resolver. If you add a new `{video.*}` or `{collab.*}` token, it must be registered in the system variables registry. ## System Variable Tokens **Video tokens:** | Token | Resolves to | |---|---| | `{video.title}` | Video title | | `{video.tags}` | Comma-separated tag list | | `{video.category}` | YouTube category name (e.g. `Gaming`). Falls back to the raw numeric ID if the category is not in the known map. There is no `{video.categoryId}` token. | | `{video.scheduledAt}` | Scheduled publish date (date token — supports `\|format`) | | `{video.recordingDate}` | Recording date (date token — supports `\|format`) | | `{video.gameTitle}` | Game title field | | `{video.language}` | Default language code | | `{video.playlists}` | Comma-separated list of playlist **titles** (e.g. `Gaming, Tutorials`) | | `{video.playlistLinks}` | Comma-separated list of full YouTube playlist **URLs** (e.g. `https://www.youtube.com/playlist?list=PLxxx`) | **Collaborator tokens:** `{collab.name}`, `{collab.youtube}`, `{collab.twitch}`, `{collab.instagram}`, `{collab.tiktok}`, `{collab.twitter}`, `{collab.bluesky}`, `{collab.discord}`, `{collab.aliases}`, `{collab.notes}` Note: `{collab.youtube}` resolves to the full URL (`https://www.youtube.com/@handle`), not just the handle. ## Multi-Collaborator Rendering Two different behaviors apply depending on block type: ### In VARIABLE, CONDITIONAL, CAMPAIGN, and freetext blocks `{collab.*}` tokens resolve against the **first** collaborator in `Video.collaboratorIds` whose record is found in the loaded collaborator list. If no collaborator is assigned, all `{collab.*}` tokens are left unreplaced. This means if a video has three collaborators, `{collab.name}` in a VARIABLE block only produces the first collaborator's name — not all three. ### In COLLABORATOR blocks The block's content template is expanded **once per assigned collaborator**, in the order they appear in `collaboratorIds`. Each expansion resolves all `{collab.*}` tokens against that specific collaborator. The resulting strings are joined with `\n\n` (double newline), or `\n` if the block has `compact: true`. **Example** — with two collaborators (Alice, Bob) and a COLLABORATOR block containing: ``` 🎮 {collab.name} — {collab.youtube} ``` Renders as: ``` 🎮 Alice — https://www.youtube.com/@alice 🎮 Bob — https://www.youtube.com/@bob ``` If a collaborator has no value for a given token (e.g. no `youtubeLink`), that token resolves to an empty string. ## Hash Computation `hashMetadata()` in `shared/render-engine/hash.ts` hashes these fields: `title`, `description`, `tags`, `categoryId`, `privacyStatus`, `defaultLanguage`, `defaultAudioLanguage`, `selfDeclaredMadeForKids`, `embeddable`, `license`, `recordingDate`. The hash is stored as `Video.lastSyncedHash` after a successful YouTube push. `hasPendingChanges` is computed by comparing current state hash against `lastSyncedHash`. ## Date Format Override Syntax Date tokens support an inline format override using `|` as a separator: ``` {video.scheduledAt|DD.MM.YYYY} {video.recordingDate|MMMM D, YYYY} ``` **Only date tokens support this.** The two date tokens are `video.scheduledAt` and `video.recordingDate`. All other `video.*` and `collab.*` tokens are non-date (`kind: 'simple'`) — the regex captures the `|format` portion for all tokens but non-date resolvers ignore it. **Format string tokens** (custom implementation, not strftime or moment.js): | Token | Output | |---|---| | `YYYY` | 4-digit year (e.g. `2024`) | | `YY` | 2-digit year (e.g. `24`) | | `MMMM` | Full month name (e.g. `January`) | | `MMM` | Short month name (e.g. `Jan`) | | `MM` | Zero-padded month (e.g. `01`) | | `M` | Month without padding (e.g. `1`) | | `DD` | Zero-padded day (e.g. `05`) | | `D` | Day without padding (e.g. `5`) | **Priority:** inline token format → team `dateFormat` setting → `YYYY-MM-DD` (hard default). **Invalid format strings** are silently passed through as-is. Any character or sequence not matching a known token is output verbatim — no error, no warning. **Null date:** If the date field is null or undefined (e.g. `video.recordingDate` not set), the token resolves to an empty string regardless of format. ## Date Formatting Implementation `applyDateFormat()` in `render-engine.service.ts` uses a single-pass regex replacement to avoid re-substitution bugs. Do not convert to chained `.replace()` calls. ## Related - [[02 - Backend]] - [[02 - Description Engine]] (features)