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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
dist/
dist-demo/
*.tsbuildinfo
.DS_Store
var/
133 changes: 133 additions & 0 deletions packages/director-mode/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# @shoploop/director-mode

Render-backend-agnostic **shot-authoring UX** for product twins.

Tap-to-drop a camera, draw a Bezier shot path, scrub a preview, save a preset, and
validate against brand-safe framing rules. Director Mode emits a portable
`DirectorShot` JSON that any backend can render — `threejs_diffusion` today,
Kit/OVRTX later — without forking the UI.

> **Omniverse-free by design.** No `@nvidia/omniverse`, no `web-viewer-sample`, no
> OVRTX, no Kit App Streaming. The render path is the existing
> **open-imagine router → threejs_diffusion**. Live Kit streaming is a deferred
> Tier-2 concern (unit economics: $30–$80/mo per concurrent operator breaks the
> $99–$299/mo Shoploop tier).

## Render path

```
twin-renderer canvas (Three.js)
↓ tap / drag events
DirectorOverlay (this package)
↓ DirectorShot JSON
validator (Kinfolk composition + brand_rules.json)
↓ submitShot() — HMAC signed
open-imagine router POST /render → threejs_diffusion
↓ Creative Pack
distribution adapters
```

## Modules

- `src/schema.ts` — `DirectorShot`, `UsdCameraPrim`, `BezierShotPath`, `AP2ProductRef`, type guards.
- `src/core/` — `shotBuilder` (tap/drag → shot), `usdSerializer` (shot ↔ `.usda`), `presetStore` (localStorage + remote-sync stub).
- `src/validator/` — `kinfolkRule`, `brandSafeZone`, `focalLengthGate`, composed by `validateShot`.
- `src/adapter/` — `openImagineSubmit` (HMAC-signed `POST /render`), `renderProvenance` (stamps `external_id`).
- `src/ui/` — React 18 overlay: `DirectorOverlay`, `CameraDropTool`, `PathDrawTool`, `Scrubber`, `PresetLibrary`.

## Brand-safe framing rules

Thresholds are configurable per brand via `brand_rules.json`.

| Rule | Default | Severity |
|---|---|---|
| `kinfolk.composition` | subject 38–62% of frame width, ≥30% negative space on one side | block |
| `brand.safe_zone` | product never within 5% of any frame edge (unless `allow_bleed`) | block |
| `focalLength` | 24–135mm (unless lens is in `focal_overrides`) | block |

## Usage

```ts
import {
buildShotFromTap,
validateShot,
submitShot,
buildProvenance,
} from "@shoploop/director-mode";

const shot = buildShotFromTap({
name: "hero-pour-3s",
position: [0.0, 1.2, 4.5],
target: [0, 0.5, 0],
product_ref: {
product_id: "gid://shopify/ProductVariant/4412345",
twin_usda_uri: "s3://varitea-twins/jasmine-pearl.usda",
brand_rules_id: "varitea-kinfolk",
},
});

const result = validateShot(shot, {
framing: { x: 0.1, y: 0.2, width: 0.5, height: 0.6 },
});
if (!result.ok) throw new Error(JSON.stringify(result.violations));

const { job_id } = await submitShot(shot, {
routerUrl: "https://open-imagine.internal/v1",
// secret falls back to process.env.OPEN_IMAGINE_HMAC_SECRET
});

// Stamp the shot id into provenance so the bandit's external_id join resolves.
const provenance = buildProvenance(shot, { twin_handle: "varitea-jasmine-pearl", preset: "hero" });
```

## DirectorShot — full JSON example

```json
{
"id": "01HZX9F8Q0R3M5T7V9XABCDEFG",
"name": "hero-pour-3s",
"camera": {
"position": [0.0, 1.2, 4.5],
"target": [0.0, 0.5, 0.0],
"focalLength": 50,
"fStop": 5.6,
"horizontalAperture": 36
},
"path": {
"p0": [0.0, 1.2, 4.5],
"p1": [1.0, 1.2, 4.0],
"p2": [2.0, 1.0, 3.2],
"p3": [2.5, 0.9, 2.8],
"targetPath": {
"p0": [0.0, 0.5, 0.0],
"p1": [0.1, 0.5, 0.0],
"p2": [0.2, 0.5, 0.0],
"p3": [0.3, 0.5, 0.0]
}
},
"duration_ms": 3000,
"product_ref": {
"product_id": "gid://shopify/ProductVariant/4412345",
"twin_usda_uri": "s3://varitea-twins/jasmine-pearl.usda",
"brand_rules_id": "varitea-kinfolk"
},
"brand_rules_id": "varitea-kinfolk",
"created_at": "2026-06-25T18:30:00.000Z",
"preset_tags": ["hero", "pack-shot"]
}
```

## Scripts

```bash
pnpm test --filter @shoploop/director-mode # vitest
pnpm --filter @shoploop/director-mode build # tsc → dist/
pnpm --filter @shoploop/director-mode typecheck
```

## Env

- `OPEN_IMAGINE_HMAC_SECRET` — shared secret for signing `POST /render`. The
signature is `base64(HMAC-SHA256(rawBody, secret))` sent as
`X-Open-Imagine-Signature`, matching the Shopify-webhook HMAC scheme already
used in this monorepo.
45 changes: 45 additions & 0 deletions packages/director-mode/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "@shoploop/director-mode",
"version": "0.1.0",
"description": "Render-backend-agnostic shot-authoring UX for product twins. Tap-to-drop camera, draw Bezier shot paths, scrub preview, save presets, validate against Kinfolk + brand-safe rules. Emits portable DirectorShot JSON rendered via the open-imagine router → threejs_diffusion. Omniverse-free by design.",
"license": "UNLICENSED",
"private": true,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"sideEffects": false,
"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"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"jsdom": "^24.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
},
"engines": {
"node": ">=20"
}
}
31 changes: 31 additions & 0 deletions packages/director-mode/src/__tests__/brandSafeZone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { brandSafeZone } from "../validator/brandSafeZone.js";
import type { BrandRules, SubjectFraming } from "../schema.js";

describe("brandSafeZone", () => {
it("passes a product fully inside the safe zone", () => {
const framing: SubjectFraming = { x: 0.2, y: 0.2, width: 0.4, height: 0.4 };
expect(brandSafeZone(framing)).toEqual([]);
});

it("blocks a product cropped by the frame edge", () => {
const framing: SubjectFraming = { x: -0.05, y: 0.2, width: 0.4, height: 0.4 };
const v = brandSafeZone(framing);
expect(v).toHaveLength(1);
expect(v[0].message).toContain("cropped");
});

it("blocks a product entirely outside the frame", () => {
const framing: SubjectFraming = { x: 1.2, y: 0.2, width: 0.3, height: 0.3 };
const v = brandSafeZone(framing);
expect(v[0].message).toContain("outside");
});

it("permits edge contact when allow_bleed is set", () => {
const framing: SubjectFraming = { x: 0.02, y: 0.2, width: 0.4, height: 0.4 };
const brand: BrandRules = { id: "b", allow_bleed: true };
expect(brandSafeZone(framing, brand)).toEqual([]);
// ...but blocks the same framing without the override.
expect(brandSafeZone(framing)).toHaveLength(1);
});
});
30 changes: 30 additions & 0 deletions packages/director-mode/src/__tests__/focalLengthGate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { focalLengthGate } from "../validator/focalLengthGate.js";
import type { BrandRules, UsdCameraPrim } from "../schema.js";

const cam = (focalLength: number): UsdCameraPrim => ({
position: [0, 0, 5],
target: [0, 0, 0],
focalLength,
fStop: 5.6,
horizontalAperture: 36,
});

describe("focalLengthGate", () => {
it("passes a 50mm lens (inside the 24–135mm default range)", () => {
expect(focalLengthGate(cam(50))).toEqual([]);
});

it("blocks an 18mm ultra-wide lens", () => {
const v = focalLengthGate(cam(18));
expect(v).toHaveLength(1);
expect(v[0].rule).toBe("focalLength");
expect(v[0].severity).toBe("block");
});

it("blocks 200mm by default but allows it when brand whitelists the lens", () => {
expect(focalLengthGate(cam(200))).toHaveLength(1);
const brand: BrandRules = { id: "b", focal_overrides: [200] };
expect(focalLengthGate(cam(200), brand)).toEqual([]);
});
});
31 changes: 31 additions & 0 deletions packages/director-mode/src/__tests__/kinfolkRule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { kinfolkRule } from "../validator/kinfolkRule.js";
import type { SubjectFraming } from "../schema.js";

describe("kinfolkRule rule-of-thirds width", () => {
it("passes a subject at 50% width with ample negative space", () => {
const framing: SubjectFraming = { x: 0.1, y: 0.2, width: 0.5, height: 0.6 };
expect(kinfolkRule(framing)).toEqual([]);
});

it("blocks a subject that fills 80% of the frame width", () => {
const framing: SubjectFraming = { x: 0.1, y: 0.1, width: 0.8, height: 0.6 };
const v = kinfolkRule(framing);
expect(v.some((x) => x.rule === "kinfolk.composition" && x.severity === "block")).toBe(true);
});
});

describe("kinfolkRule negative space", () => {
it("passes when ≥30% negative space exists on one side", () => {
// width 0.4 hugging the left → right space = 0.5 (>0.30)
const framing: SubjectFraming = { x: 0.1, y: 0.2, width: 0.4, height: 0.6 };
expect(kinfolkRule(framing)).toEqual([]);
});

it("blocks a perfectly centered subject with <30% on both sides", () => {
// width 0.5 centered → each side 0.25 (<0.30)
const framing: SubjectFraming = { x: 0.25, y: 0.2, width: 0.5, height: 0.6 };
const v = kinfolkRule(framing);
expect(v.some((x) => x.message.includes("negative space"))).toBe(true);
});
});
98 changes: 98 additions & 0 deletions packages/director-mode/src/__tests__/openImagineSubmit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, vi } from "vitest";
import { createHmac } from "node:crypto";
import { signPayload, submitShot } from "../adapter/openImagineSubmit.js";
import { buildShotFromTap } from "../core/shotBuilder.js";
import type { DirectorShot } from "../schema.js";

const SECRET = "test-secret";

function makeShot(): DirectorShot {
return buildShotFromTap({
name: "hero",
position: [0, 0, 5],
product_ref: { product_id: "p", twin_usda_uri: "file://t", brand_rules_id: "default" },
now: 0,
});
}

function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}

describe("openImagineSubmit happy path", () => {
it("POSTs to /render with the threejs_diffusion backend and returns the job", async () => {
const shot = makeShot();
const fetchImpl = vi.fn(async () => jsonResponse(200, { job_id: "job-1", status: "queued" }));
const result = await submitShot(shot, {
routerUrl: "https://router.local/v1/",
secret: SECRET,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(result).toEqual({ job_id: "job-1", status: "queued" });
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("https://router.local/v1/render");
const body = JSON.parse(init.body as string);
expect(body).toEqual({ shot, backend: "threejs_diffusion" });
});
});

describe("openImagineSubmit HMAC signing", () => {
it("signs the raw body with base64(HMAC-SHA256) under OPEN_IMAGINE_HMAC_SECRET", async () => {
const shot = makeShot();
let seenSig = "";
let seenBody = "";
const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => {
seenBody = init.body as string;
seenSig = (init.headers as Record<string, string>)["x-open-imagine-signature"];
return jsonResponse(200, { job_id: "j", status: "queued" });
});
await submitShot(shot, {
routerUrl: "https://router.local",
secret: SECRET,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const expected = createHmac("sha256", SECRET).update(Buffer.from(seenBody, "utf8")).digest("base64");
expect(seenSig).toBe(expected);
expect(seenSig).toBe(signPayload(seenBody, SECRET));
});
});

describe("openImagineSubmit retry on 503", () => {
it("retries a transient 503 then succeeds", async () => {
const shot = makeShot();
let calls = 0;
const fetchImpl = vi.fn(async () => {
calls += 1;
if (calls < 3) return jsonResponse(503, { error: "cold" });
return jsonResponse(200, { job_id: "job-9", status: "queued" });
});
const result = await submitShot(shot, {
routerUrl: "https://router.local",
secret: SECRET,
fetchImpl: fetchImpl as unknown as typeof fetch,
maxRetries: 3,
retryDelayMs: 0,
});
expect(calls).toBe(3);
expect(result.job_id).toBe("job-9");
});

it("throws after exhausting retries on persistent 503", async () => {
const shot = makeShot();
const fetchImpl = vi.fn(async () => jsonResponse(503, { error: "down" }));
await expect(
submitShot(shot, {
routerUrl: "https://router.local",
secret: SECRET,
fetchImpl: fetchImpl as unknown as typeof fetch,
maxRetries: 2,
retryDelayMs: 0,
}),
).rejects.toThrow(/503/);
expect(fetchImpl).toHaveBeenCalledTimes(3);
});
});
Loading