Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
dist/
dist-demo/
*.tsbuildinfo
.DS_Store
4 changes: 4 additions & 0 deletions packages/animated-shorts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
var
*.tsbuildinfo
28 changes: 28 additions & 0 deletions packages/animated-shorts/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# DESIGN — @shoploop/animated-shorts

- **Deterministic > real-time.** Every visual — camera transform, variant selection, material
finish, overlay opacity — is a pure function of `(storyboard, frame)`, evaluated against
Remotion's frame counter. No `requestAnimationFrame`, no wall-clock, no drift. The same
storyboard renders the same bytes every time, which is what makes the bandit's scores mean
something.

- **Storyboard as code.** A short is a tiny typed DSL: a product twin plus an ordered list of
keyframe beats, validated against `storyboard.v1.json`. Hand-authored or generated, diffable,
reviewable. If it starts to look like After Effects, it's wrong.

- **Kinfolk gating at compose time.** `brand_rules.json` is enforced before the render, not
after. The orchestrator composes the scene for every variant the storyboard visits and scores
it; a storyboard that would emit an off-brand frame is refused with `BrandRulesViolation` and
its reasons. The moat is brand-constraint enforcement, not pixels.

- **Bandit-ready provenance.** Each render emits a `creative_pack.v1` (identical to the still
pipeline) plus a `RenderProvenance` record onto the same `BANDIT_QUEUE_PATH` the Google Ads
connector writes performance to. Thompson Sampling joins the two streams by
`(twin_handle × preset)` — shorts and stills are scored as the same kind of arm.

- **Two-mode render, one output.** `local` uses `@remotion/renderer` for $0 dev renders;
`open-imagine` dispatches to the GPU router (`POST /v1/render` → poll `GET /v1/jobs/:id`).
Both paths return the same Creative Pack, so callers — and the bandit — never branch on where
a short was rendered. The Three.js scene comes from the SAME parser + scene builder as
`@shoploop/twin-renderer` (re-exported, not duplicated), so offline shorts and the live web
twin stay pixel-for-pixel in agreement.
110 changes: 110 additions & 0 deletions packages/animated-shorts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# @shoploop/animated-shorts

Deterministic vertical ad shorts (9:16, **1080×1920**, 6s/9s/15s) for Varitea, built from
the SAME USD product twins the live web renderer uses. Three.js scenes are embedded inside
[Remotion](https://www.remotion.dev/) compositions and driven entirely off Remotion's frame
counter — **frame-accurate, no real-time animation, no drift.**

```
USD twin (.usda) → composed scene → Three.js animated capture (Remotion) → encoded MP4 → Creative Pack
```

## Why this exists

A short is just another creative the bandit can score. It emits the identical
`creative_pack.v1` shape a still does, so the Thompson Sampling loop joins shorts and stills
by `(twin_handle × preset)` without caring which pipeline produced them.

## Quickstart — 6s Varitea hero short, locally

From this package directory:

```bash
pnpm install
pnpm cli render --storyboard storyboards/varitea_jasmine_hero_6s.json
# → var/shorts/varitea_jasmine_hero_6s.mp4 (1080×1920, 6s, H.264 CRF 18)
```

Or via the installed bin (after `pnpm build`):

```bash
shoploop-shorts render --storyboard storyboards/varitea_jasmine_hero_6s.json --out hero.mp4
```

First run downloads a headless Chromium shell for Remotion (~90 MB), then renders at $0 on
your machine.

## Render the 15s flavors short via open-imagine

The render can run locally (default) or be dispatched to the offline GPU router. Same
storyboard, same Creative Pack out:

```bash
export SHOPLOOP_ROUTER_URL=http://localhost:8080
export SHOPLOOP_ROUTER_TOKEN=... # optional bearer
shoploop-shorts render \
--storyboard storyboards/varitea_flavors_lifestyle_15s.json \
--mode open-imagine
```

`open-imagine` mode `POST`s `{ stage_uri, storyboard_uri, … }` to `/v1/render`, polls
`GET /v1/jobs/:id` until done, and returns the Creative Pack the router produced. In this
PR the router's `blender_cpu` stub keeps that path at $0.

## The five primitives

| # | Primitive | File |
|---|---|---|
| 1 | Storyboard DSL (+ `storyboard.v1.json`) | `src/dsl/storyboard.ts` |
| 2 | Remotion composer | `src/composer/Short.tsx` |
| 3 | Three.js bridge | `src/three/ThreeCanvas.tsx` |
| 4 | Render orchestrator (local + router) | `src/render/orchestrator.ts` |
| 5 | Bandit feedback bridge | `src/feedback/observation.ts` |

## Storyboard DSL

A storyboard is a product twin plus an ordered list of **beats** (keyframes). If it starts to
look like After Effects, it's wrong.

```jsonc
{
"id": "varitea_jasmine_hero_6s",
"duration_s": 6,
"preset": "hero",
"product_twin": "storyboards/assets/varitea_jasmine_pearl.usda",
"brand_rules": "storyboards/assets/brand_rules.json",
"beats": [
{ "t_s": 0, "kind": "camera", "preset": "orbit", "ease": "ease-in-out" },
{ "t_s": 2, "kind": "overlay", "text": "Jasmine Pearl", "placement": "lower-third", "duration_s": 3 }
]
}
```

Beat kinds: `camera` (`orbit` | `dolly` | `macro` | `still`), `variant` (timeline keyframe,
never a runtime UI), `material` (`matte` | `satin` | `gloss`), `overlay`. Validated against
`schema/storyboard.v1.json`.

Checked-in Varitea storyboards:
- `storyboards/varitea_jasmine_hero_6s.json` — 6s hero, orbit camera, single lower-third overlay.
- `storyboards/varitea_flavors_lifestyle_15s.json` — 15s lifestyle, dolly-in, flavor flip at 5s & 10s, centered end card.

## Kinfolk gate

`brand_rules.json` is applied at **scene-compose time**. Before a single frame renders, the
orchestrator composes the scene for every variant the storyboard visits and scores it
(palette, finish, camera). If a frame would fail the Kinfolk test, the render is **refused**
with a `BrandRulesViolation` listing the reasons — never silently shipped.

## Env

```
SHOPLOOP_ROUTER_URL=http://localhost:8080
SHOPLOOP_ROUTER_TOKEN=
BANDIT_QUEUE_PATH=./var/bandit/queue.jsonl
```

## Test

```bash
pnpm test # dsl schema · composer 1-frame · local MP4 render · router mock · Kinfolk refusal
```
2 changes: 2 additions & 0 deletions packages/animated-shorts/bin/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import "../dist/cli.js";
49 changes: 49 additions & 0 deletions packages/animated-shorts/contracts/creative_pack.v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "creative_pack.v1",
"title": "CreativePack",
"description": "A renderable creative output (still OR short) plus the brand/provenance metadata the Thompson Sampling loop needs to score it. Shorts emit the SAME shape as stills so the bandit treats them identically. EVENTUAL HOME: open-imagine/contracts/creative_pack.v1.json — vendored here until promoted.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "twin_handle", "preset", "format", "assets", "brand_score", "created_at"],
"properties": {
"schema": { "const": "creative_pack.v1" },
"id": { "type": "string", "minLength": 1 },
"twin_handle": {
"type": "string",
"description": "Stable product-twin handle (commerce:handle). Bandit arm join key, paired with preset."
},
"preset": { "type": "string", "enum": ["hero", "lifestyle", "pdp", "ugc"] },
"format": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "width", "height"],
"properties": {
"kind": { "type": "string", "enum": ["still", "short"] },
"width": { "type": "integer" },
"height": { "type": "integer" },
"duration_s": { "type": "number" },
"fps": { "type": "integer" }
}
},
"assets": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["role", "uri", "mime"],
"properties": {
"role": { "type": "string", "enum": ["primary", "thumbnail", "preview"] },
"uri": { "type": "string" },
"mime": { "type": "string" },
"bytes": { "type": "integer", "minimum": 0 }
}
}
},
"brand_score": { "type": "number", "minimum": 0, "maximum": 1 },
"storyboard_id": { "type": "string" },
"render_mode": { "type": "string", "enum": ["local", "open-imagine"] },
"created_at": { "type": "string", "format": "date-time" }
}
}
57 changes: 57 additions & 0 deletions packages/animated-shorts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "@shoploop/animated-shorts",
"version": "0.1.0",
"description": "Deterministic vertical ad shorts (9:16, 1080×1920) for Varitea. Three.js product twins driven by Remotion's frame counter, encoded to MP4 locally or dispatched to open-imagine. Storyboard-as-code, Kinfolk-gated, bandit-ready.",
"license": "UNLICENSED",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"bin": {
"shoploop-shorts": "./bin/cli.js"
},
"files": [
"dist",
"bin",
"schema",
"storyboards"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"cli": "tsx src/cli.ts",
"render": "tsx src/cli.ts render"
},
"dependencies": {
"@remotion/bundler": "^4.0.482",
"@remotion/renderer": "^4.0.482",
"@remotion/three": "^4.0.482",
"@shoploop/twin-renderer": "workspace:*",
"ajv": "^8.17.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"remotion": "^4.0.482",
"three": "^0.169.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/three": "^0.169.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
},
"engines": {
"node": ">=20"
}
}
114 changes: 114 additions & 0 deletions packages/animated-shorts/schema/storyboard.v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "storyboard.v1",
"title": "Storyboard",
"description": "A deterministic vertical-short timeline: a product twin plus ordered keyframe beats. Consumed by the Remotion composer, the Three.js bridge, the Kinfolk gate, and the render orchestrator.",
"type": "object",
"additionalProperties": false,
"required": ["id", "duration_s", "beats", "product_twin", "brand_rules", "preset"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"duration_s": { "type": "integer", "enum": [6, 9, 15] },
"preset": { "type": "string", "enum": ["hero", "lifestyle", "pdp", "ugc"] },
"product_twin": {
"type": "string",
"minLength": 1,
"description": "Path or s3:// uri to the product_twin .usda."
},
"brand_rules": {
"type": "string",
"minLength": 1,
"description": "Path to brand_rules.json applied at scene-compose time."
},
"audio": {
"type": "object",
"additionalProperties": false,
"required": ["uri"],
"properties": {
"uri": { "type": "string", "minLength": 1 },
"gain_db": { "type": "number" }
}
},
"captions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["t_s", "text", "duration_s"],
"properties": {
"t_s": { "type": "number", "minimum": 0 },
"text": { "type": "string" },
"duration_s": { "type": "number", "exclusiveMinimum": 0 },
"placement": { "type": "string", "enum": ["lower-third", "corner", "centered"] }
}
}
},
"beats": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["t_s", "kind"],
"properties": {
"t_s": { "type": "number", "minimum": 0 },
"kind": { "type": "string", "enum": ["camera", "variant", "material", "overlay"] }
},
"allOf": [
{
"if": { "properties": { "kind": { "const": "camera" } } },
"then": {
"additionalProperties": false,
"required": ["t_s", "kind", "preset"],
"properties": {
"t_s": { "type": "number", "minimum": 0 },
"kind": { "const": "camera" },
"preset": { "type": "string", "enum": ["orbit", "dolly", "macro", "still"] },
"ease": { "type": "string", "enum": ["linear", "ease-in-out"] }
}
}
},
{
"if": { "properties": { "kind": { "const": "variant" } } },
"then": {
"additionalProperties": false,
"required": ["t_s", "kind", "set", "option"],
"properties": {
"t_s": { "type": "number", "minimum": 0 },
"kind": { "const": "variant" },
"set": { "type": "string", "minLength": 1 },
"option": { "type": "string", "minLength": 1 }
}
}
},
{
"if": { "properties": { "kind": { "const": "material" } } },
"then": {
"additionalProperties": false,
"required": ["t_s", "kind", "mesh", "finish"],
"properties": {
"t_s": { "type": "number", "minimum": 0 },
"kind": { "const": "material" },
"mesh": { "type": "string", "minLength": 1 },
"finish": { "type": "string", "enum": ["matte", "satin", "gloss"] }
}
}
},
{
"if": { "properties": { "kind": { "const": "overlay" } } },
"then": {
"additionalProperties": false,
"required": ["t_s", "kind", "text", "placement", "duration_s"],
"properties": {
"t_s": { "type": "number", "minimum": 0 },
"kind": { "const": "overlay" },
"text": { "type": "string" },
"placement": { "type": "string", "enum": ["lower-third", "corner", "centered"] },
"duration_s": { "type": "number", "exclusiveMinimum": 0 }
}
}
}
]
}
}
}
}
Loading