Skip to content
Merged
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 docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,11 @@ Cost tier: `free`
- `noTranscribe` _(boolean)_ — Skip Whisper word-level transcribe step (no transcript-<id>.json emitted)
- `transcribeLanguage` _(string)_ — BCP-47 language code passed to Whisper (e.g. en, ko)
- `force` _(boolean)_ — Overwrite an existing compositions/scene-<id>.html
- `lottie` _(string)_ — Lottie animation file (.json/.lottie) to overlay on the scene
- `lottiePosition` _(string)_ _(full \| center \| top-left \| top-right \| bottom-left \| bottom-right)_ _(default: `"full"`)_ — Lottie position: full, center, top-left, top-right, bottom-left, bottom-right
- `lottieScale` _(number)_ — Lottie overlay scale (0.01-2)
- `lottieOpacity` _(number)_ _(default: `1`)_ — Lottie overlay opacity (0-1)
- `lottieNoLoop` _(boolean)_ — Do not loop the Lottie animation
- `dryRun` _(boolean)_ — Preview parameters without writing files or calling APIs

#### `vibe scene compose-prompts`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3459,6 +3459,36 @@ exports[`CLI --describe schemas (drift detection) > vibe scene add --describe 1`
"description": "Small label above the headline (explainer / product-shot)",
"type": "string",
},
"lottie": {
"description": "Lottie animation file (.json/.lottie) to overlay on the scene",
"type": "string",
},
"lottieNoLoop": {
"description": "Do not loop the Lottie animation",
"type": "boolean",
},
"lottieOpacity": {
"default": 1,
"description": "Lottie overlay opacity (0-1)",
"type": "number",
},
"lottiePosition": {
"default": "full",
"description": "Lottie position: full, center, top-left, top-right, bottom-left, bottom-right",
"enum": [
"full",
"center",
"top-left",
"top-right",
"bottom-left",
"bottom-right",
],
"type": "string",
},
"lottieScale": {
"description": "Lottie overlay scale (0.01-2)",
"type": "number",
},
"name": {
"description": "Scene name (slugified into the composition id)",
"type": "string",
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/commands/_shared/scene-html-emit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,3 +630,115 @@ describe("emitSceneHtml — robust defaults across input variability", () => {
}
});
});

// ── Lottie overlay layer ────────────────────────────────────────────────────
//
// Regression coverage for the bug where the emit path pointed at a
// non-existent CDN package (`@nicepkg/dotlottie-wc`), so `<dotlottie-wc>`
// never registered and the overlay silently did not render. These tests
// pin the loader to the real package (`@lottiefiles/dotlottie-wc`),
// require an explicit `setWasmUrl(...)` call (the runtime needs its
// WASM player or it 404s), and lock in the per-scene id so multiple
// scenes with overlays don't collide.

describe("emitSceneHtml — Lottie overlay layer", () => {
const lottieBase = {
...baseInput,
preset: "simple" as const,
lottie: {
src: "assets/lottie-intro.json",
position: "full" as const,
},
};

it("renders nothing Lottie-related when no overlay is provided", () => {
const html = emitSceneHtml({ ...baseInput, preset: "simple" });
expect(html).not.toContain("dotlottie-wc");
expect(html).not.toContain("setWasmUrl");
});

it("loads the runtime from the real @lottiefiles/dotlottie-wc package on jsdelivr", () => {
const html = emitSceneHtml(lottieBase);
// The custom element + a module script that registers it must both ship.
expect(html).toContain("<dotlottie-wc");
expect(html).toContain('src="assets/lottie-intro.json"');
expect(html).toContain("@lottiefiles/dotlottie-wc");
// Guard against regression to the broken package name.
expect(html).not.toContain("@nicepkg/dotlottie-wc");
// Pinned version must track packages/cli/package.json (^0.9.12).
expect(html).toMatch(/@lottiefiles\/dotlottie-wc@0\.9\.\d+/);
});

it("calls setWasmUrl with the dotlottie-web WASM URL so the player can render", () => {
const html = emitSceneHtml(lottieBase);
expect(html).toContain("setWasmUrl");
expect(html).toContain("@lottiefiles/dotlottie-web");
expect(html).toMatch(/dotlottie-web@0\.71\.\d+\/dist\/dotlottie-player\.wasm/);
});

it("scopes the overlay id per scene to avoid collisions between scenes", () => {
const a = emitSceneHtml({ ...lottieBase, id: "intro" });
const b = emitSceneHtml({ ...lottieBase, id: "outro" });
expect(a).toContain('id="lottie-overlay-intro"');
expect(b).toContain('id="lottie-overlay-outro"');
// No leftover unscoped id from the original implementation.
expect(a).not.toContain('id="lottie-overlay"');
});

it("emits autoplay + loop by default and drops loop when loop:false", () => {
const looping = emitSceneHtml(lottieBase);
expect(looping).toMatch(/<dotlottie-wc[^>]*\sautoplay\s+loop\b/);

const oneShot = emitSceneHtml({
...lottieBase,
lottie: { ...lottieBase.lottie, loop: false },
});
expect(oneShot).toMatch(/<dotlottie-wc[^>]*\sautoplay\b/);
expect(oneShot).not.toMatch(/<dotlottie-wc[^>]*\sloop\b/);
});

it("defaults non-full positions to scale 0.25 (matches html-clips.ts)", () => {
const html = emitSceneHtml({
...lottieBase,
lottie: { src: "assets/x.json", position: "bottom-right" },
});
// Default corner scale is 0.25 → 25% width/height (not 100% as the prior bug emitted).
expect(html).toContain("width:25%");
expect(html).toContain("height:25%");
});

it("full position ignores scale and fills the scene", () => {
const html = emitSceneHtml({
...lottieBase,
lottie: { src: "assets/x.json", position: "full", scale: 0.5 },
});
expect(html).toContain("inset:0");
expect(html).toContain("width:100%");
expect(html).toContain("height:100%");
});

it("clamps scale to [0.01, 2] and opacity to [0, 1]", () => {
const overscale = emitSceneHtml({
...lottieBase,
lottie: { src: "assets/x.json", position: "top-left", scale: 5, opacity: 5 },
});
expect(overscale).toContain("width:200%");
expect(overscale).toContain("opacity:1");

const underscale = emitSceneHtml({
...lottieBase,
lottie: { src: "assets/x.json", position: "top-left", scale: -1, opacity: -1 },
});
expect(underscale).toContain("width:1%");
expect(underscale).toContain("opacity:0");
});

it("escapes the lottie src to prevent HTML injection via the file path", () => {
const html = emitSceneHtml({
...lottieBase,
lottie: { src: 'evil"><script>alert(1)</script>', position: "full" },
});
expect(html).not.toContain("<script>alert(1)");
expect(html).toContain("&quot;");
});
});
141 changes: 139 additions & 2 deletions packages/cli/src/commands/_shared/scene-html-emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,40 @@ export interface SceneTranscriptWord {
end: number;
}

/**
* Position value for Lottie overlays. Mirrors the vocabulary used by
* `vibe edit motion-overlay --position`.
*/
export type LottiePosition =
| "full"
| "center"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";

export const LOTTIE_POSITIONS: readonly LottiePosition[] = [
"full",
"center",
"top-left",
"top-right",
"bottom-left",
"bottom-right",
] as const;

export interface LottieOverlayInput {
/** Project-relative path to the `.json` or `.lottie` file (e.g. "assets/logo.lottie"). */
src: string;
/** Overlay position. Defaults to "full". */
position?: LottiePosition;
/** Scale factor (0.01-2). Defaults to 1. */
scale?: number;
/** Opacity (0-1). Defaults to 1. */
opacity?: number;
/** Whether the animation loops. Defaults to true. */
loop?: boolean;
}

export interface EmitSceneInput {
/** Kebab-case scene id; appears in `data-composition-id` and template id. */
id: string;
Expand Down Expand Up @@ -69,6 +103,12 @@ export interface EmitSceneInput {
* their headlines are intentionally static.
*/
transcript?: SceneTranscriptWord[];
/**
* Optional Lottie animation overlay. When supplied, a `<dotlottie-wc>`
* element is layered on top of the preset's content. Uses the same
* position/scale/opacity vocabulary as `vibe edit motion-overlay`.
*/
lottie?: LottieOverlayInput;
}

const GSAP_CDN = "https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js";
Expand Down Expand Up @@ -534,6 +574,99 @@ function buildPreset(input: Required<Pick<EmitSceneInput, "id" | "preset" | "dur
}
}

// ---------------------------------------------------------------------------
// Lottie overlay layer
// ---------------------------------------------------------------------------

// `@lottiefiles/dotlottie-wc` is the real package used by the pipeline
// renderer (see `pipeline/renderers/html-template.ts` + `project-builder.ts`).
// The scene-emit path can't vendor the runtime into the project dir without
// adding an asset-copy step to `executeSceneAdd`, so we load it from
// jsdelivr the same way we load GSAP. The WASM URL must be set explicitly
// because dotLottie's web component otherwise tries to fetch the WASM
// from a path relative to the importing module — which would 404 on the
// jsdelivr CDN (the WASM lives in the sibling `@lottiefiles/dotlottie-web`
// package, not in `dotlottie-wc/dist/`).
const DOTLOTTIE_WC_VERSION = "0.9.12";
const DOTLOTTIE_WEB_VERSION = "0.71.0";
const DOTLOTTIE_WC_CDN = `https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-wc@${DOTLOTTIE_WC_VERSION}/dist/index.js`;
const DOTLOTTIE_WASM_CDN = `https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@${DOTLOTTIE_WEB_VERSION}/dist/dotlottie-player.wasm`;

/**
* Build inline style for a positioned Lottie overlay. Mirrors the logic
* in `html-clips.ts → lottieInnerStyle()` so the visual result is
* identical to `vibe edit motion-overlay`.
*
* Defaults match `html-clips.ts`:
* - scale defaults to 1 for `full`, 0.25 for any other position
* - opacity defaults to 1
* - scale is clamped to [0.01, 2], opacity to [0, 1]
*/
function lottieOverlayStyle(input: LottieOverlayInput): string {
const pos = input.position ?? "full";
const rawScale = input.scale ?? (pos === "full" ? 1 : 0.25);
const scale = Math.max(0.01, Math.min(2, rawScale));
const opacity = Math.max(0, Math.min(1, input.opacity ?? 1));

const base = [
"position:absolute",
"pointer-events:none",
`opacity:${opacity}`,
];

if (pos === "full") {
base.push("inset:0", "width:100%", "height:100%");
} else {
const pct = scale * 100;
base.push(`width:${pct}%`, `height:${pct}%`);
switch (pos) {
case "center":
base.push("top:50%", "left:50%", "transform:translate(-50%,-50%)");
break;
case "top-left":
base.push("top:4%", "left:4%");
break;
case "top-right":
base.push("top:4%", "right:4%");
break;
case "bottom-left":
base.push("bottom:4%", "left:4%");
break;
case "bottom-right":
base.push("bottom:4%", "right:4%");
break;
}
}
return base.join(";");
}

/**
* Build the `<dotlottie-wc>` markup + module script for a Lottie overlay
* layer. Returns `{ markup, script }` to splice into the scene template.
*
* The script imports the web component module and calls `setWasmUrl(...)`
* with an explicit CDN URL so the dotLottie runtime can locate its WASM
* player. Without this call, the WASM fetch fails and the overlay never
* paints. See the equivalent setup in
* `pipeline/renderers/html-template.ts` (which vendors the runtime
* locally for the producer-served pipeline path).
*/
function buildLottieOverlay(
input: LottieOverlayInput,
sceneId: string,
): { markup: string; script: string } {
const loop = (input.loop ?? true) ? " loop" : "";
const style = lottieOverlayStyle(input);
// Per-scene id avoids DOM-id collisions when multiple scenes carry overlays.
const overlayId = `lottie-overlay-${sceneId}`;
const markup = `<dotlottie-wc id="${overlayId}" src="${esc(input.src)}" autoplay${loop} style="${style}"></dotlottie-wc>`;
const script = `<script type="module">
import { setWasmUrl } from "${DOTLOTTIE_WC_CDN}";
setWasmUrl("${DOTLOTTIE_WASM_CDN}");
</script>`;
return { markup, script };
}

/**
* Emit the full per-scene HTML. Returns a complete `<template>`-wrapped
* composition ready to write to `compositions/scene-<id>.html`.
Expand Down Expand Up @@ -561,15 +694,19 @@ export function emitSceneHtml(input: EmitSceneInput): string {
></audio>\n`
: "";

const lottieLayer = input.lottie ? buildLottieOverlay(input.lottie, id) : null;
const lottieMarkup = lottieLayer ? `\n ${lottieLayer.markup}` : "";
const lottieScript = lottieLayer ? `\n ${lottieLayer.script}` : "";

return `<template id="scene-${id}-template">
<div data-composition-id="${id}" data-start="0" data-duration="${dur}" data-width="${input.width}" data-height="${input.height}">
<style>
${parts.css}
</style>

${parts.body}
${parts.body}${lottieMarkup}
${audioBlock}
<script src="${GSAP_CDN}"></script>
<script src="${GSAP_CDN}"></script>${lottieScript}
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
Expand Down
Loading
Loading