docs(media): Media Studio guide, license table and shipped platform profiles

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
This commit is contained in:
Claude 2026-10-03 19:17:20 +00:00
parent 884d8903ca
commit 1f91a4a727
No known key found for this signature in database
17 changed files with 1515 additions and 0 deletions

View File

@ -85,3 +85,16 @@ Das evOo-Ökosystem ist in klar getrennte, orthogonale Säulen unterteilt:
1. **Kein Müllcode**: Code ist so kompakt, elegant und wartbar wie möglich. 1. **Kein Müllcode**: Code ist so kompakt, elegant und wartbar wie möglich.
2. **Atmendes Interface**: Die Benutzeroberfläche von evOo visualisiert den vitalen Zustand des Systems in Echtzeit durch organische, atmende Pulsation (Breathing Animation). 2. **Atmendes Interface**: Die Benutzeroberfläche von evOo visualisiert den vitalen Zustand des Systems in Echtzeit durch organische, atmende Pulsation (Breathing Animation).
3. **Resilienz**: Fällt ein Dienst aus, fängt die Plattform den Fehler ab, protokolliert die Ursache und stellt den Dienst autonom wieder her. 3. **Resilienz**: Fällt ein Dienst aus, fängt die Plattform den Fehler ab, protokolliert die Ursache und stellt den Dienst autonom wieder her.
---
## Media Studio
Optionales Modul für Mediathek, Schnitt, Anonymisierung, Brand-Kit und plattformkonformen Export. Ohne konfigurierten Media-Worker bleibt es verborgen. Details, Setup und Lizenzen: [`docs/media.md`](docs/media.md).
- **Das Modell plant, die Engine rendert.** LLMs liefern nur validierte Daten (Schnittplan als EDL, Texte, Bewertungen). Rendering, Anonymisierung, Compliance-Gate und Einwilligungsprüfung sind deterministischer Code; ffmpeg läuft nur mit argv-Arrays.
- **Schichten:** reine Domänenlogik in `packages/media`, Contracts in `packages/contracts/src/media.ts`, Interfaces in `packages/adapter-kit`, Adapter und Jobs in `packages/adapters/src/media-*.ts` (Composition Root `media-services.ts`), RPCs in `apps/api/src/media.ts`, Python-Sidecar mit lokalen Modellen in `infra/media-worker` (Compose-Profil `media`, eine generische Verbindung `MEDIA_WORKER_URL` + `MEDIA_WORKER_TOKEN`).
- **Jobs:** `media.sync`, `media.proxy`, `media.analyze`, `media.cluster`, `media.render`, `media.publish` im vorhandenen Graphile-Worker, idempotent pro Inhalt und Analyseversion.
- **Fail closed:** Das Gate prüft den gerenderten Output (Technik, Inhalt, Gesichter, Einwilligung, Text, KI-Kennzeichnung). Kann es keinen sicheren Zustand nachweisen, wird nicht exportiert.
- **Privatsphäre:** Biometrische Daten bleiben lokal, Exporte werden von Metadaten bereinigt, Einwilligungen setzt nur der Nutzer in der UI.
- **Profile:** Öffentliche Social-Profile liegen in `packages/media/profiles/`; private Profile (Bezahl-Plattformen, Strategie) nur in `MEDIA_PROFILES_DIR` außerhalb des Repos.

331
docs/media.md Normal file
View File

@ -0,0 +1,331 @@
# Media Studio
Media Studio indexes a photo and video library, groups it into sessions and
themes, cuts videos per platform, anonymizes people and identifying marks,
applies a brand kit, and exports platform-ready bundles with tracking links and
results. It is optional: without a configured media worker it stays hidden
(`media.capabilities` reports it unconfigured) and its jobs are skipped.
Everything runs locally: ffmpeg plus local models in a sidecar. No hosted
vendor is required. A language model is used only to plan (edit plans, copy,
theme labels, teaser verdicts) and only with AI consent; rendering,
anonymization, the compliance gate and consent checks are deterministic code.
## Architecture
```
Sources ──► MediaSourceProvider ──► ingest jobs ──► analysis (sidecar) ──► index (Postgres)
(folder, Apple Photos via desktop, │
iOS/Android upload, emulator) ▼
Agent (media_* tools) ──► sessions, themes, teaser scores, edit plan (EDL, validated)
│
▼
anonymization ──► render (ffmpeg + motion overlays) ──► gate (output re-checked) ──► bundle / publisher
│
tracking links ──► metrics ──► teaser learning
```
| Layer | Where | Role |
|---|---|---|
| Domain logic | `packages/media` (`@rakazo/media`) | Pure, I/O-free: EDL schema and validation, EDL → render plan and ffmpeg argv, session clustering, teaser scoring and learning, gate rules, profile loading and merge, UTM builder, metadata checks. |
| Contracts | `packages/contracts/src/media.ts` | zod DTOs, RPC shapes (`mediaRpcShapes`), agent tool inputs (`MediaToolInputSchemas`), `PlatformProfileSchema`. |
| Interfaces | `packages/adapter-kit` | `MediaSourceProvider`, `MediaInferenceProvider` (capability-based), `MediaRenderer`, `MotionGraphicsRenderer`, `MediaPublisher`, `LinkShortener`. |
| Adapters | `packages/adapters/src/media-*.ts` | Sidecar client, deterministic emulator, folder source, ffmpeg renderer, resvg motion renderer, bundle publisher, ingest/render/publish jobs, privacy, gate, marketing. `media-services.ts` is the composition root. |
| Sidecar | `infra/media-worker` | Python/FastAPI service with the models. Bound to loopback or the internal compose network, bearer token. |
| Data | `packages/db` | `Media*` Prisma models, all scoped to a space and deleted with it. |
| API | `apps/api/src/media.ts`, `media-http.ts` | RPC procedures and byte routes (thumbnails, proxies, ranged render files, bundles, chunked upload). |
| Clients | `apps/web` (also hosted by Electron), `apps/mobile`, `apps/desktop/native/photos-helper` | Library, projects, review, brand kit, results; mobile index and resumable upload; macOS Photos helper. |
Clients have no realtime subscription for media: they poll the relevant RPC
while work is in progress (for example every 2 s while a render is `queued`,
`rendering` or `gating`) and stop when it is idle.
## Setup
### Sidecar (compose profile `media`)
1. Set the generic connection in `.env` (placeholders, see `.env.example`):
```sh
MEDIA_WORKER_URL=http://media-worker:8790 # bare-metal dev: http://127.0.0.1:8790
MEDIA_WORKER_TOKEN=REPLACE_WITH_RANDOM_TOKEN # at least 32 random characters
```
2. Start the stack with the profile:
```sh
docker compose -f infra/compose/docker-compose.yml --profile media up -d
```
The sidecar publishes no port; the API and worker reach it on the compose
network. It mounts `./data/media` at `/data/media`, the same path the API and
worker use as `DATA_DIR/media`, so file paths in requests resolve on both
sides.
3. Choose the accelerator with `MEDIA_ACCELERATOR=cpu|cuda|rocm` (build arg,
default `cpu`). The CUDA image uses the torch cu126 wheels and
`onnxruntime-gpu`. The ROCm image runs SAM 2 on the GPU; ONNX Runtime and
CTranslate2 have no ROCm wheels on PyPI, so SigLIP, LaMa and Whisper run on
the CPU there. A GPU image falls back to CPU when no GPU is visible.
4. Model weights are not in the repository or the image. They download on first
use into the `media-models` volume, are checked against pinned SHA256 hashes
and sizes (`infra/media-worker/app/models.py`), and are deleted on mismatch.
Warm the volume ahead of time with
`docker compose run --rm media-worker python -m app prefetch`; set
`MEDIA_MODEL_DOWNLOAD=0` to forbid downloads (missing weights then report the
operation as unsupported).
Sidecar variables (`MEDIA_ROOT`, concurrency, size and time limits,
`MEDIA_OPTIONAL_BACKENDS`) are documented in `infra/media-worker/README.md`.
There is one connection for the API, never a variable per model; model choice
is sidecar configuration.
### API and worker variables
| Variable | Default | Purpose |
|---|---|---|
| `MEDIA_WORKER_URL`, `MEDIA_WORKER_TOKEN` | unset | The sidecar connection. Both set → Media Studio is enabled. |
| `MEDIA_PROFILES_DIR` | `~/.evoo/media-profiles` | Private platform profiles (below). |
| `MEDIA_GPU_CONCURRENCY` | `1` | Model-bound media jobs per worker process. |
| `MEDIA_CPU_CONCURRENCY` | `2` | ffmpeg-bound media jobs per worker process. |
| `DATA_DIR` | `./data` | Derived files live under `DATA_DIR/media`. |
The API and worker run ffmpeg and ffprobe themselves (proxies, renders,
probes); they must be on `PATH`. Hardware encoders (VAAPI, VideoToolbox, NVENC)
are used when detected, with software x264 as the fallback.
With `AGENT_RUNTIME=scripted` the deterministic emulator replaces the sidecar
and an emulator source provides synthetic clips. Tests and web E2E run this way,
offline.
### Private profiles directory
Shipped profiles live in `packages/media/profiles/`. Profiles for paid or adult
platforms, real brand assets and any marketing strategy never go into this
repository. Put them in `MEDIA_PROFILES_DIR`, outside the repo:
- Every `*.json` file there is validated against `PlatformProfileSchema`.
- A private profile with the same `id` as a shipped one replaces it.
- An invalid file is skipped whole and logged; it is never half-loaded.
In compose the API and worker run in containers, so `~` is the container's
home. Point the variable at a mounted path instead, for example
`MEDIA_PROFILES_DIR=/data/media-profiles` (that is `./data/media-profiles` on
the host, which is git-ignored).
## How the pipeline works
1. **Sources.** A folder source (only the deployment owner may add one, the same
rule as host folders), Apple Photos through the macOS desktop helper
(PhotoKit, macOS only; the source is not offered elsewhere), the mobile
library index with a resumable chunked upload, and the emulator. Originals are
referenced by source, external id and a streamed content hash, never copied.
2. **Ingest jobs** (Graphile, idempotent, resumable, cancellable):
`media.sync` (metadata only, 500 per page, cursor based), `media.proxy`
(720p H.264 proxy, poster and sprite, rotation applied, HDR tone-mapped,
variable frame rate normalized), `media.analyze` (scenes, transcript with
word times, faces and tracks, frame embeddings, content scores, loudness and
beats, saliency), `media.cluster` (sessions, themes, face clusters). A new
model version adds a new analysis row and never overwrites. A full disk
pauses a job with a clear message.
3. **Sessions and themes.** A new session starts after a gap over 45 minutes
(configurable), a jump over 2 km, or a new calendar day with a gap over 3 h;
neighbouring sessions with mean-embedding cosine ≥ 0.85 and a gap under 2 h
merge. Themes are agglomerative clusters of embeddings. Theme labels come
from a model only with AI consent; otherwise they are rule based (place,
time, album) and can be renamed.
4. **Edit plan.** The agent (or the user) writes an EDL (`edlVersion: 1`). It is
validated against asset durations, the profile's hard duration limits, track
overlaps, references and safe zones; errors are machine-readable so the agent
can correct them.
5. **Render.** `compileRenderPlan` turns the EDL into argv arrays (never a shell
string; IDs restricted to `[a-z0-9_-]`, text escaped). Sources are redacted
first, motion overlays (intros, outros, lower thirds, captions, end cards)
are pre-rendered from brand-kit-only templates, then ffmpeg renders a draft
from proxies or the final from originals, with two-pass `loudnorm` to the
profile target (default −14 LUFS, −1 dBTP).
6. **Gate.** Before any export, on the rendered output: technical probe,
content classifier at the profile's sample rate plus every cut, faces
re-detected and matched to people, pixel-level check that every protected
region is covered, consent and age proof where required, text against
banned patterns, link policy and lengths, and AI-alteration disclosure. Any
failure is `blocked`. The agent may repair a blocked render only with
compliance-preserving changes (trim or drop a clip, other segment, other
crop, wider cover) for at most 3 rounds; then the user takes over.
7. **Marketing.** Three thumbnail variants (also gated), per-profile copy
checked against the profile, UTM tracking links (no shortener by default),
and an export bundle: video, posters, SRT/VTT, `caption.txt`,
`metadata.json`, `UPLOAD-CHECKLIST.md`. Paid platforms only ever get the
bundle.
8. **Scheduling and results.** A scheduled publication is a `media.publish` job
with a run time; at that time the bundle is built, handed to the publisher
(by default the bundle itself) and the author gets a push to upload it.
Uploads to connected accounts go through the agent's connector tools, where
the existing approval rules apply. Metrics come from CSV import
with saved column mappings or manual entry and feed teaser learning, which
starts after 20 publications.
## Platform profiles
A profile (`PlatformProfileSchema` in `packages/contracts/src/media.ts`) is one
JSON file:
| Field | Meaning |
|---|---|
| `id`, `name`, `kind` | `id` matches `[a-z0-9_-]`; `kind` is `free_social`, `paid_video`, `own_site` or `messenger`. |
| `sourceUrls`, `reviewedAt`, `unverified` | Where the values come from, when they were reviewed, and whether any value is unconfirmed. The UI flags unverified profiles and those reviewed more than 90 days ago. |
| `video` | Aspect ratios, width, height, fps, codec (`h264`, `hevc`), max bitrate, max file size. |
| `duration` | `hardMinMs`, `hardMaxMs`, `recommended[]`, `hookWindowMs`. |
| `safeZones` | Margins in percent kept free of overlays. |
| `captions` | Recommended, required, max lines. |
| `audio` | Target LUFS, true peak, music policy. |
| `text` | Title and caption length, hashtag limit, `linkPolicy`, `bannedPatterns[]` (case-insensitive regex with a reason). |
| `content` | Max classifier score per category, sample fps, `blockOn`. |
| `disclosure` | AI-altered label, paid-partnership label, age-restricted flag. |
| `consent` | Performer consent and age verification. A `paid_video` profile always requires both, whatever the file says. |
| `export` | Bundle contents and an optional preview clip length. |
Shipped profiles:
| File | Notes |
|---|---|
| `youtube-shorts`, `youtube`, `tiktok`, `instagram-reels`, `instagram-feed-4x5`, `facebook-reels`, `x-twitter`, `linkedin`, `snapchat-spotlight`, `pinterest-video-pin` | Conservative defaults for public social platforms. All are `unverified: true`: the official pages in `sourceUrls` could not be fetched while these files were written, so values come from previously published documentation and are set on the strict side. Review each against its sources and clear the flag, or override it privately. Pinterest's former Idea Pins are now regular video Pins. |
| `example-short-vertical`, `example-landscape` | Neutral examples. |
| `example-paid-video` | Neutral placeholder for a paid video platform: consent and age proof required, age-restricted flag set. |
| `own-site-hls` | Your own site. The render is a progressive MP4; HLS packaging is left to the site. |
## Privacy
- **Biometric data stays local.** Face detections, tracks, face embeddings and
person clusters are computed by the local sidecar or emulator and stored in
the local database and `DATA_DIR/media`. They are never sent to an external
service, with or without AI consent.
- **AI consent.** Any media sent to a non-local model (theme labels from cover
thumbnails, teaser verdicts, copy) passes the existing AI-consent gate.
Without consent the deterministic path is used.
- **Consent and age proof** for performers can only be recorded by the signed-in
user in the UI. Agent tools cannot write them.
- **Deletion.** Deleting a person removes their face tracks, embeddings, marks
and mask files. Deleting a space cascades to every media row; derived files
live under `DATA_DIR/media/<spaceId>/`.
- **Metadata stripping.** Every export drops container and stream metadata
(`-map_metadata -1`, bitexact muxing), so GPS, device serials and encoder
software are removed and only allowed fields (title) are set again. An
ffprobe check after the render proves it; otherwise the render fails.
Posters are extracted without metadata and thumbnail variants are written as
PNG without EXIF or text chunks.
- **Locations** are stored rounded.
- **Paths.** Every file path goes through one function that rejects traversal,
absolute inputs and symlinks leaving the media root. The sidecar enforces the
same rule for `MEDIA_ROOT`.
What it does not guarantee: anonymization is only as good as detection. Low
confidence keeps the last mask and enlarges it, detection gaps over 500 ms
block the export, and the gate re-checks the output, but a person the detector
never sees (back of the head, heavy occlusion) is not recognised as a person.
Voice alteration (pitch or formant) obscures a voice; it is not anonymization.
Review the output before publishing.
## Hard limits
These are not built and must not be added:
- Anything that evades platform moderation or classifiers (adversarial
overlays, inserted frames, colour or noise tricks, age-gate bypass).
"Compliance" means the content actually follows the rules.
- Face swaps with a real third person. Only a synthetic, non-existent identity
is allowed, and the API accepts no target-face image.
- Export to paid platforms without documented consent and age proof of every
recognised participant. Unmatched faces block.
- Uploading biometric data to external services.
Failures fail closed: if anonymization, the gate or the consent check cannot
show a safe state, nothing is exported.
## Licenses
Checked October 2026 against the upstream license files listed in
`infra/media-worker/LICENSES.md` and the package metadata installed in this
repository. "Commercial use" means the code or weights license permits it;
training-data terms were not reviewed legally. No non-commercial model is a
default. Weights are downloaded at runtime and never shipped.
### Models
| Name | Purpose | License | Commercial use | Default / optional |
|---|---|---|---|---|
| OpenCV YuNet 2023mar | Face detection, 5-point landmarks, tracking input | MIT | yes | default |
| Whisper small (faster-whisper / CTranslate2 conversion) | Transcription with word timestamps | MIT | yes | default |
| SigLIP base patch16-224 (ONNX) | Frame embeddings | Apache-2.0 | yes | default |
| SigLIP base patch16-224 (ONNX), zero-shot prompts | Content classifier | Apache-2.0 | yes | default, experimental |
| SAM 2.1 Hiera tiny | Prompted segmentation with video propagation | Apache-2.0 | yes | default, experimental |
| Big-LaMa (ONNX) + optical flow | Per-frame inpainting with temporal smoothing | Apache-2.0 | yes | default, experimental |
| OpenCV SFace 2021dec | Face identity embeddings for local person clustering | Apache-2.0 weights; training-data provenance unverified | unclear | optional (`MEDIA_OPTIONAL_BACKENDS=faceIdentity`) |
| Synthetic identity generator and swap | `synthesizeIdentity` | none found with a commercial license | — | unavailable; always reports unsupported |
| ProPainter | Video inpainting | S-Lab License 1.0 (non-commercial) | no | not used |
| InsightFace model zoo (SCRFD, ArcFace, inswapper) | Detection, recognition, swap | code MIT, models non-commercial | no | not used |
| NudeNet v3 | Nudity detection | AGPL-3.0 | copyleft | not used |
| MediaPipe Face Landmarker | Dense landmarks | Apache-2.0 | yes | not used |
| OpenCLIP | Embeddings | MIT | yes | not used |
### Methods and tools
| Name | Purpose | License | Commercial use | Default / optional |
|---|---|---|---|---|
| FFmpeg / ffprobe | Decode, render, loudness, probes | LGPL-2.1+ or GPL-2+ depending on the build | yes, run as a separate process | default |
| PySceneDetect (AdaptiveDetector) | Shot detection | BSD-3-Clause | yes | default |
| librosa | Beats and tempo | ISC | yes | default |
| IoU tracker, spectral-residual saliency, redaction | Tracking, saliency, blur/pixelate/cover | this repository (Apache-2.0) | yes | default |
| `@resvg/resvg-js` 2.6.2 | Motion overlays: SVG frames rasterized, then encoded by ffmpeg | MPL-2.0 | yes, used unmodified as a library | default |
| `sharp` 0.35.4 (bundles libvips) | Thumbnail and poster processing | Apache-2.0; prebuilt libvips LGPL-3.0-or-later | yes, dynamically linked | default |
| `expo-media-library`, `expo-video` | Mobile library index and preview | MIT | yes | default on mobile |
| PhotoKit | Apple Photos access in the desktop helper | Apple system framework | yes, on macOS | default on macOS |
### Sidecar libraries
FastAPI (MIT), Uvicorn (BSD-3-Clause), Pydantic (MIT), NumPy (BSD-3-Clause),
OpenCV `opencv-python-headless` (Apache-2.0, wheel bundles LGPL FFmpeg),
soundfile (BSD-3-Clause), PyTorch and torchvision (BSD-3-Clause), ONNX Runtime
(MIT), faster-whisper (MIT), CTranslate2 (MIT), Hugging Face tokenizers
(Apache-2.0), `sam2` (Apache-2.0), Hydra and iopath (MIT). All are commercially
usable.
### Remotion
The spec proposed Remotion for motion graphics. It is not used: Remotion
requires a paid company license above a team-size threshold and needs headless
Chromium. The motion renderer instead draws each template as an SVG per frame,
rasterizes it with resvg and encodes a QuickTime Animation (alpha) clip with
ffmpeg, behind the same `MotionGraphicsRenderer` interface.
The license entries above were taken from the upstream license files as
recorded in `infra/media-worker/LICENSES.md`, the pinned registry in
`infra/media-worker/app/models.py` and the installed npm package metadata. The
upstream pages themselves could not be fetched again while this document was
written; re-check them before relying on this table for a release.
## Known limitations
- **Content classifier is experimental.** It is zero-shot SigLIP with text
prompts, not a trained moderation model. Treat its scores as a first filter;
it does not replace a platform's own review. Segmentation and inpainting are
experimental too, and inpainting with a low quality score falls back to a
cover or reframe suggestion.
- **Synthetic identity is unavailable.** No commercially licensed model was
found, so the mode reports "not available".
- **CUDA and ROCm images are unbuilt in CI.** Only the CPU image is built and
tested; the GPU variants are defined but untested.
- **Job limits are per worker process.** `MEDIA_GPU_CONCURRENCY` and
`MEDIA_CPU_CONCURRENCY` bound one worker; several workers multiply them. The
sidecar's own gates (`MEDIA_GPU_CONCURRENCY`, `MEDIA_QUEUE_SIZE`) still cap
inference and answer `busy` when full.
- **Motion graphics use resvg, not Remotion** (see above): templates are SVG,
so effects needing a browser engine (CSS, WebGL) are not available.
- **Scheduled publishing** is a `media.publish` background job with a run time,
not a Routine. Rescheduling replaces the job and cancelling removes it.
- **Platform profiles are unverified** until someone checks them against the
official pages listed in each file.
- **Own site output** is a progressive MP4; no HLS ladder is produced.
- **Apple Photos** needs the macOS desktop app; the source is not offered on
Windows, Linux or the web.

View File

@ -0,0 +1,70 @@
{
"id": "example-landscape",
"name": "Example landscape",
"kind": "free_social",
"sourceUrls": [],
"reviewedAt": "2026-10-03",
"unverified": false,
"video": {
"aspectRatios": ["16:9"],
"width": 1920,
"height": 1080,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 10000,
"maxFileSizeMb": 2048
},
"duration": {
"hardMinMs": 1000,
"hardMaxMs": 600000,
"recommended": [30000, 60000, 180000],
"hookWindowMs": 3000
},
"safeZones": {
"topPct": 5,
"bottomPct": 10,
"leftPct": 5,
"rightPct": 5
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"titleMaxChars": 100,
"captionMaxChars": 1000,
"hashtagLimit": 5,
"linkPolicy": "allowed",
"bannedPatterns": []
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"minors_risk": 0.1,
"violence": 0.5
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": false,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,68 @@
{
"id": "example-paid-video",
"name": "Example paid video",
"kind": "paid_video",
"sourceUrls": [],
"reviewedAt": "2026-10-03",
"unverified": false,
"video": {
"aspectRatios": ["16:9"],
"width": 1920,
"height": 1080,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 12000,
"maxFileSizeMb": 4000
},
"duration": {
"hardMinMs": 1000,
"hardMaxMs": 3600000,
"recommended": [],
"hookWindowMs": 2000
},
"safeZones": {
"topPct": 0,
"bottomPct": 0,
"leftPct": 0,
"rightPct": 0
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"titleMaxChars": 100,
"captionMaxChars": 2000,
"hashtagLimit": 10,
"linkPolicy": "allowed",
"bannedPatterns": []
},
"content": {
"thresholds": {
"minors_risk": 0.1
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": false,
"ageRestrictedFlag": true
},
"consent": {
"requirePerformerConsent": true,
"requireAgeVerification": true
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,75 @@
{
"id": "example-short-vertical",
"name": "Example short vertical",
"kind": "free_social",
"sourceUrls": [],
"reviewedAt": "2026-10-03",
"unverified": false,
"video": {
"aspectRatios": ["9:16"],
"width": 1080,
"height": 1920,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 8000,
"maxFileSizeMb": 500
},
"duration": {
"hardMinMs": 1000,
"hardMaxMs": 60000,
"recommended": [7000, 15000, 30000],
"hookWindowMs": 2000
},
"safeZones": {
"topPct": 10,
"bottomPct": 20,
"leftPct": 5,
"rightPct": 12
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"titleMaxChars": 100,
"captionMaxChars": 150,
"hashtagLimit": 3,
"linkPolicy": "bio_only",
"bannedPatterns": [
{
"pattern": "free\\s+money",
"reason": "Misleading claim."
}
]
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"minors_risk": 0.1,
"violence": 0.5
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": false,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,82 @@
{
"id": "facebook-reels",
"name": "Facebook Reels",
"kind": "free_social",
"sourceUrls": [
"https://www.facebook.com/help/",
"https://www.facebook.com/business/ads-guide/",
"https://transparency.meta.com/policies/community-standards/"
],
"reviewedAt": "2026-10-03",
"unverified": true,
"video": {
"aspectRatios": ["9:16"],
"width": 1080,
"height": 1920,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 5000,
"maxFileSizeMb": 1024
},
"duration": {
"hardMinMs": 3000,
"hardMaxMs": 90000,
"recommended": [15000, 30000, 60000],
"hookWindowMs": 2000
},
"safeZones": {
"topPct": 10,
"bottomPct": 20,
"leftPct": 5,
"rightPct": 15
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"captionMaxChars": 2200,
"hashtagLimit": 10,
"linkPolicy": "bio_only",
"bannedPatterns": [
{
"pattern": "\\b(sub4sub|follow4follow|f4f|like4like)\\b",
"reason": "Engagement bait; the spam policies of the major platforms prohibit it."
}
]
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"suggestive": 0.6,
"violence": 0.5,
"minors_risk": 0.1,
"self_harm": 0.3,
"drugs": 0.5,
"weapons": 0.6
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": true,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,82 @@
{
"id": "instagram-feed-4x5",
"name": "Instagram Feed 4:5",
"kind": "free_social",
"sourceUrls": [
"https://help.instagram.com/",
"https://www.facebook.com/business/ads-guide/",
"https://transparency.meta.com/policies/community-standards/"
],
"reviewedAt": "2026-10-03",
"unverified": true,
"video": {
"aspectRatios": ["4:5"],
"width": 1080,
"height": 1350,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 5000,
"maxFileSizeMb": 1024
},
"duration": {
"hardMinMs": 3000,
"hardMaxMs": 60000,
"recommended": [15000, 30000],
"hookWindowMs": 2000
},
"safeZones": {
"topPct": 5,
"bottomPct": 10,
"leftPct": 5,
"rightPct": 5
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"captionMaxChars": 2200,
"hashtagLimit": 5,
"linkPolicy": "bio_only",
"bannedPatterns": [
{
"pattern": "\\b(sub4sub|follow4follow|f4f|like4like)\\b",
"reason": "Engagement bait; the spam policies of the major platforms prohibit it."
}
]
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"suggestive": 0.6,
"violence": 0.5,
"minors_risk": 0.1,
"self_harm": 0.3,
"drugs": 0.5,
"weapons": 0.6
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": true,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,82 @@
{
"id": "instagram-reels",
"name": "Instagram Reels",
"kind": "free_social",
"sourceUrls": [
"https://help.instagram.com/",
"https://www.facebook.com/business/ads-guide/",
"https://transparency.meta.com/policies/community-standards/"
],
"reviewedAt": "2026-10-03",
"unverified": true,
"video": {
"aspectRatios": ["9:16"],
"width": 1080,
"height": 1920,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 5000,
"maxFileSizeMb": 1024
},
"duration": {
"hardMinMs": 3000,
"hardMaxMs": 90000,
"recommended": [7000, 15000, 30000],
"hookWindowMs": 2000
},
"safeZones": {
"topPct": 10,
"bottomPct": 20,
"leftPct": 5,
"rightPct": 15
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"captionMaxChars": 2200,
"hashtagLimit": 5,
"linkPolicy": "bio_only",
"bannedPatterns": [
{
"pattern": "\\b(sub4sub|follow4follow|f4f|like4like)\\b",
"reason": "Engagement bait; the spam policies of the major platforms prohibit it."
}
]
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"suggestive": 0.6,
"violence": 0.5,
"minors_risk": 0.1,
"self_harm": 0.3,
"drugs": 0.5,
"weapons": 0.6
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": true,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,82 @@
{
"id": "linkedin",
"name": "LinkedIn",
"kind": "free_social",
"sourceUrls": [
"https://www.linkedin.com/help/linkedin/",
"https://www.linkedin.com/legal/professional-community-policies"
],
"reviewedAt": "2026-10-03",
"unverified": true,
"video": {
"aspectRatios": ["16:9", "1:1", "4:5"],
"width": 1920,
"height": 1080,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 8000,
"maxFileSizeMb": 5000
},
"duration": {
"hardMinMs": 3000,
"hardMaxMs": 600000,
"recommended": [30000, 60000, 90000],
"hookWindowMs": 3000
},
"safeZones": {
"topPct": 5,
"bottomPct": 10,
"leftPct": 5,
"rightPct": 5
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"titleMaxChars": 200,
"captionMaxChars": 3000,
"hashtagLimit": 5,
"linkPolicy": "allowed",
"bannedPatterns": [
{
"pattern": "\\b(sub4sub|follow4follow|f4f|like4like)\\b",
"reason": "Engagement bait; the spam policies of the major platforms prohibit it."
}
]
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"suggestive": 0.6,
"violence": 0.5,
"minors_risk": 0.1,
"self_harm": 0.3,
"drugs": 0.5,
"weapons": 0.6
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": true,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,68 @@
{
"id": "own-site-hls",
"name": "Own site",
"kind": "own_site",
"sourceUrls": [],
"reviewedAt": "2026-10-03",
"unverified": false,
"video": {
"aspectRatios": ["16:9", "9:16", "1:1"],
"width": 1920,
"height": 1080,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 12000,
"maxFileSizeMb": 8000
},
"duration": {
"hardMinMs": 1000,
"hardMaxMs": 14400000,
"recommended": [],
"hookWindowMs": 3000
},
"safeZones": {
"topPct": 0,
"bottomPct": 0,
"leftPct": 0,
"rightPct": 0
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"titleMaxChars": 120,
"captionMaxChars": 5000,
"hashtagLimit": 0,
"linkPolicy": "allowed",
"bannedPatterns": []
},
"content": {
"thresholds": {
"minors_risk": 0.1
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": false,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,82 @@
{
"id": "pinterest-video-pin",
"name": "Pinterest video Pin",
"kind": "free_social",
"sourceUrls": [
"https://help.pinterest.com/en/business",
"https://policy.pinterest.com/en/community-guidelines"
],
"reviewedAt": "2026-10-03",
"unverified": true,
"video": {
"aspectRatios": ["9:16"],
"width": 1080,
"height": 1920,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 8000,
"maxFileSizeMb": 2048
},
"duration": {
"hardMinMs": 4000,
"hardMaxMs": 300000,
"recommended": [6000, 15000, 30000],
"hookWindowMs": 2000
},
"safeZones": {
"topPct": 10,
"bottomPct": 20,
"leftPct": 5,
"rightPct": 15
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"titleMaxChars": 100,
"captionMaxChars": 500,
"hashtagLimit": 5,
"linkPolicy": "allowed",
"bannedPatterns": [
{
"pattern": "\\b(sub4sub|follow4follow|f4f|like4like)\\b",
"reason": "Engagement bait; the spam policies of the major platforms prohibit it."
}
]
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"suggestive": 0.6,
"violence": 0.5,
"minors_risk": 0.1,
"self_harm": 0.3,
"drugs": 0.5,
"weapons": 0.6
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": true,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,81 @@
{
"id": "snapchat-spotlight",
"name": "Snapchat Spotlight",
"kind": "free_social",
"sourceUrls": [
"https://help.snapchat.com/hc/en-us",
"https://values.snap.com/policy/policy-community-guidelines"
],
"reviewedAt": "2026-10-03",
"unverified": true,
"video": {
"aspectRatios": ["9:16"],
"width": 1080,
"height": 1920,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 5000,
"maxFileSizeMb": 250
},
"duration": {
"hardMinMs": 5000,
"hardMaxMs": 60000,
"recommended": [10000, 15000, 30000],
"hookWindowMs": 2000
},
"safeZones": {
"topPct": 10,
"bottomPct": 20,
"leftPct": 5,
"rightPct": 15
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"captionMaxChars": 160,
"hashtagLimit": 3,
"linkPolicy": "forbidden",
"bannedPatterns": [
{
"pattern": "\\b(sub4sub|follow4follow|f4f|like4like)\\b",
"reason": "Engagement bait; the spam policies of the major platforms prohibit it."
}
]
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"suggestive": 0.6,
"violence": 0.5,
"minors_risk": 0.1,
"self_harm": 0.3,
"drugs": 0.5,
"weapons": 0.6
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": true,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,82 @@
{
"id": "tiktok",
"name": "TikTok",
"kind": "free_social",
"sourceUrls": [
"https://support.tiktok.com/en/using-tiktok/creating-videos/upload-videos",
"https://support.tiktok.com/en/using-tiktok/creating-videos/ai-generated-content",
"https://www.tiktok.com/community-guidelines/en"
],
"reviewedAt": "2026-10-03",
"unverified": true,
"video": {
"aspectRatios": ["9:16"],
"width": 1080,
"height": 1920,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 8000,
"maxFileSizeMb": 287
},
"duration": {
"hardMinMs": 3000,
"hardMaxMs": 600000,
"recommended": [7000, 15000, 30000, 60000],
"hookWindowMs": 2000
},
"safeZones": {
"topPct": 10,
"bottomPct": 20,
"leftPct": 5,
"rightPct": 15
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"captionMaxChars": 2200,
"hashtagLimit": 5,
"linkPolicy": "bio_only",
"bannedPatterns": [
{
"pattern": "\\b(sub4sub|follow4follow|f4f|like4like)\\b",
"reason": "Engagement bait; the spam policies of the major platforms prohibit it."
}
]
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"suggestive": 0.6,
"violence": 0.5,
"minors_risk": 0.1,
"self_harm": 0.3,
"drugs": 0.5,
"weapons": 0.6
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": true,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,81 @@
{
"id": "x-twitter",
"name": "X (Twitter)",
"kind": "free_social",
"sourceUrls": [
"https://help.x.com/en/using-x/x-videos",
"https://help.x.com/en/rules-and-policies/x-rules"
],
"reviewedAt": "2026-10-03",
"unverified": true,
"video": {
"aspectRatios": ["16:9", "1:1"],
"width": 1280,
"height": 720,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 5000,
"maxFileSizeMb": 512
},
"duration": {
"hardMinMs": 1000,
"hardMaxMs": 140000,
"recommended": [15000, 30000, 45000],
"hookWindowMs": 2000
},
"safeZones": {
"topPct": 5,
"bottomPct": 10,
"leftPct": 5,
"rightPct": 5
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"captionMaxChars": 280,
"hashtagLimit": 2,
"linkPolicy": "allowed",
"bannedPatterns": [
{
"pattern": "\\b(sub4sub|follow4follow|f4f|like4like)\\b",
"reason": "Engagement bait; the spam policies of the major platforms prohibit it."
}
]
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"suggestive": 0.6,
"violence": 0.5,
"minors_risk": 0.1,
"self_harm": 0.3,
"drugs": 0.5,
"weapons": 0.6
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": true,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,84 @@
{
"id": "youtube-shorts",
"name": "YouTube Shorts",
"kind": "free_social",
"sourceUrls": [
"https://support.google.com/youtube/answer/15424877",
"https://support.google.com/youtube/answer/10059070",
"https://support.google.com/youtube/answer/14328491",
"https://www.youtube.com/howyoutubeworks/policies/community-guidelines/"
],
"reviewedAt": "2026-10-03",
"unverified": true,
"video": {
"aspectRatios": ["9:16"],
"width": 1080,
"height": 1920,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 12000,
"maxFileSizeMb": 2048
},
"duration": {
"hardMinMs": 1000,
"hardMaxMs": 180000,
"recommended": [15000, 30000, 60000],
"hookWindowMs": 2000
},
"safeZones": {
"topPct": 10,
"bottomPct": 20,
"leftPct": 5,
"rightPct": 15
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"titleMaxChars": 100,
"captionMaxChars": 5000,
"hashtagLimit": 15,
"linkPolicy": "bio_only",
"bannedPatterns": [
{
"pattern": "\\b(sub4sub|follow4follow|f4f|like4like)\\b",
"reason": "Engagement bait; the spam policies of the major platforms prohibit it."
}
]
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"suggestive": 0.6,
"violence": 0.5,
"minors_risk": 0.1,
"self_harm": 0.3,
"drugs": 0.5,
"weapons": 0.6
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": true,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,84 @@
{
"id": "youtube",
"name": "YouTube (16:9)",
"kind": "free_social",
"sourceUrls": [
"https://support.google.com/youtube/answer/1722171",
"https://support.google.com/youtube/answer/71673",
"https://support.google.com/youtube/answer/14328491",
"https://www.youtube.com/howyoutubeworks/policies/community-guidelines/"
],
"reviewedAt": "2026-10-03",
"unverified": true,
"video": {
"aspectRatios": ["16:9"],
"width": 1920,
"height": 1080,
"fps": 30,
"codec": "h264",
"maxBitrateKbps": 8000,
"maxFileSizeMb": 4096
},
"duration": {
"hardMinMs": 1000,
"hardMaxMs": 900000,
"recommended": [60000, 180000, 480000],
"hookWindowMs": 5000
},
"safeZones": {
"topPct": 5,
"bottomPct": 10,
"leftPct": 5,
"rightPct": 5
},
"captions": {
"recommended": true,
"required": false,
"maxLines": 2
},
"audio": {
"targetLufs": -14,
"truePeakDb": -1,
"music": "licensed_only"
},
"text": {
"titleMaxChars": 100,
"captionMaxChars": 5000,
"hashtagLimit": 15,
"linkPolicy": "allowed",
"bannedPatterns": [
{
"pattern": "\\b(sub4sub|follow4follow|f4f|like4like)\\b",
"reason": "Engagement bait; the spam policies of the major platforms prohibit it."
}
]
},
"content": {
"thresholds": {
"explicit_nudity": 0.2,
"suggestive": 0.6,
"violence": 0.5,
"minors_risk": 0.1,
"self_harm": 0.3,
"drugs": 0.5,
"weapons": 0.6
},
"sampleFps": 2,
"blockOn": "any_frame"
},
"disclosure": {
"aiAlteredLabelRequired": true,
"paidPartnershipLabel": true,
"ageRestrictedFlag": false
},
"consent": {
"requirePerformerConsent": false,
"requireAgeVerification": false
},
"export": {
"poster": true,
"subtitles": true,
"captionText": true,
"uploadChecklist": true
}
}

View File

@ -0,0 +1,68 @@
import { readdir } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { BUILTIN_PROFILES_DIR, loadProfilesFromDir } from "./node.js";
describe("shipped platform profiles", () => {
it("all load without errors, one per file, named by id", async () => {
const { profiles, errors } = await loadProfilesFromDir(BUILTIN_PROFILES_DIR);
expect(errors).toEqual([]);
const files = (await readdir(BUILTIN_PROFILES_DIR)).filter((name) => name.endsWith(".json"));
expect(profiles.map((profile) => `${profile.id}.json`).sort()).toEqual(files.sort());
});
it("include the social platforms and the neutral examples", async () => {
const { profiles } = await loadProfilesFromDir(BUILTIN_PROFILES_DIR);
const ids = profiles.map((profile) => profile.id);
for (const id of [
"youtube-shorts",
"youtube",
"tiktok",
"instagram-reels",
"x-twitter",
"linkedin",
"example-short-vertical",
"example-paid-video",
]) {
expect(ids).toContain(id);
}
});
it("cite sources or are placeholders, and real platforms stay unverified until reviewed", async () => {
const { profiles } = await loadProfilesFromDir(BUILTIN_PROFILES_DIR);
for (const profile of profiles) {
const placeholder = profile.sourceUrls.length === 0;
if (placeholder) {
expect(profile.id === "own-site-hls" || profile.id.startsWith("example-")).toBe(true);
} else {
expect(profile.unverified).toBe(true);
for (const url of profile.sourceUrls) expect(url.startsWith("https://")).toBe(true);
}
}
});
it("ship no real paid platform: the only paid profile is the neutral example", async () => {
const { profiles } = await loadProfilesFromDir(BUILTIN_PROFILES_DIR);
const paid = profiles.filter((profile) => profile.kind === "paid_video");
expect(paid.map((profile) => profile.id)).toEqual(["example-paid-video"]);
expect(paid[0]?.consent).toEqual({
requirePerformerConsent: true,
requireAgeVerification: true,
});
});
it("keep caption-safe zones inside the frame and durations ordered", async () => {
const { profiles } = await loadProfilesFromDir(BUILTIN_PROFILES_DIR);
for (const profile of profiles) {
const { topPct, bottomPct, leftPct, rightPct } = profile.safeZones;
expect(topPct + bottomPct).toBeLessThan(100);
expect(leftPct + rightPct).toBeLessThan(100);
for (const ms of profile.duration.recommended) {
expect(ms).toBeGreaterThanOrEqual(profile.duration.hardMinMs);
expect(ms).toBeLessThanOrEqual(profile.duration.hardMaxMs);
}
for (const { pattern } of profile.text.bannedPatterns) {
expect(() => new RegExp(pattern, "i")).not.toThrow();
}
}
});
});