Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user