Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)

This commit is contained in:
2026-08-11 12:27:44 +02:00
commit d5af006443
304 changed files with 74604 additions and 0 deletions
@@ -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)