@mevdragon/vidfarm-devcli 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -75,6 +75,269 @@ export function normalizeKenBurns(value) {
75
75
  origin
76
76
  };
77
77
  }
78
+ // ---------------------------------------------------------------------------
79
+ // Scene transitions. Same declarative contract as Ken Burns: the INCOMING
80
+ // clip (the element that owns data-start) carries data-transition="<preset>"
81
+ // plus data-transition-duration and the inline --vf-tr-dur var, and a static
82
+ // vf-tr-* keyframe animates the clip IN over its first data-transition-duration
83
+ // seconds. Both preview runtimes and the render producer's CSS adapter scrub
84
+ // the animation by (time - data-start), so the motion is identical everywhere.
85
+ //
86
+ // The one thing CSS cannot express is the OUTGOING clip staying on screen
87
+ // beneath the incoming one: stored compositions keep scenes butt-cut (A ends
88
+ // exactly where B starts) so timeline edits and agent edits stay trivial. The
89
+ // preview runtime extends the previous same-track clip's active window live,
90
+ // and normalizePublishHtml materializes the same overlap into data-duration at
91
+ // publish/render time (recorded in data-transition-hold for idempotency),
92
+ // because the render engine derives visibility strictly from data-start/
93
+ // data-duration. Presets animate opacity / clip-path / standalone translate /
94
+ // scale — never `transform` — so they compose with a Ken Burns transform on
95
+ // the same element. Keyframes deliberately omit the `to` state so the
96
+ // animation settles into the element's own inline styles instead of
97
+ // clobbering a custom opacity.
98
+ export const TRANSITION_PRESETS = [
99
+ "crossfade",
100
+ "fade-black",
101
+ "slide-left",
102
+ "slide-right",
103
+ "slide-up",
104
+ "slide-down",
105
+ "wipe-left",
106
+ "wipe-right",
107
+ "wipe-up",
108
+ "wipe-down",
109
+ "zoom-in",
110
+ "zoom-out",
111
+ "circle-open"
112
+ ];
113
+ export const TRANSITION_DEFAULT_DURATION = 0.5;
114
+ export const TRANSITION_MIN_DURATION = 0.1;
115
+ export const TRANSITION_MAX_DURATION = 2.5;
116
+ // Two butt-cut clips can drift by a few ms through timeline edits; treat ends
117
+ // within this window as adjacent when granting the outgoing clip's hold.
118
+ export const TRANSITION_ADJACENCY_EPSILON = 0.05;
119
+ export const TRANSITION_STYLE_MARKER = "__VF_TRANSITIONS_V1__";
120
+ export const TRANSITION_CSS = `/*${TRANSITION_STYLE_MARKER}*/
121
+ @keyframes vf-tr-crossfade { from { opacity: 0; } }
122
+ @keyframes vf-tr-fade-black { 0% { opacity: 0; filter: brightness(0); } 35% { opacity: 1; filter: brightness(0); } }
123
+ @keyframes vf-tr-slide-left { from { translate: 100% 0; } }
124
+ @keyframes vf-tr-slide-right { from { translate: -100% 0; } }
125
+ @keyframes vf-tr-slide-up { from { translate: 0 100%; } }
126
+ @keyframes vf-tr-slide-down { from { translate: 0 -100%; } }
127
+ @keyframes vf-tr-wipe-left { from { clip-path: inset(0 0 0 100%); } to { clip-path: inset(0 0 0 0); } }
128
+ @keyframes vf-tr-wipe-right { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
129
+ @keyframes vf-tr-wipe-up { from { clip-path: inset(100% 0 0 0); } to { clip-path: inset(0 0 0 0); } }
130
+ @keyframes vf-tr-wipe-down { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0 0); } }
131
+ @keyframes vf-tr-zoom-in { from { opacity: 0; scale: 1.18; } }
132
+ @keyframes vf-tr-zoom-out { from { opacity: 0; scale: 0.82; } }
133
+ @keyframes vf-tr-circle-open { from { clip-path: circle(0% at 50% 50%); } to { clip-path: circle(75% at 50% 50%); } }
134
+ [data-transition] {
135
+ animation-duration: var(--vf-tr-dur, 0.5s);
136
+ animation-timing-function: var(--vf-tr-ease, ease-in-out);
137
+ animation-fill-mode: both;
138
+ animation-play-state: paused;
139
+ }
140
+ [data-transition="crossfade"] { animation-name: vf-tr-crossfade; }
141
+ [data-transition="fade-black"] { animation-name: vf-tr-fade-black; }
142
+ [data-transition="slide-left"] { animation-name: vf-tr-slide-left; }
143
+ [data-transition="slide-right"] { animation-name: vf-tr-slide-right; }
144
+ [data-transition="slide-up"] { animation-name: vf-tr-slide-up; }
145
+ [data-transition="slide-down"] { animation-name: vf-tr-slide-down; }
146
+ [data-transition="wipe-left"] { animation-name: vf-tr-wipe-left; }
147
+ [data-transition="wipe-right"] { animation-name: vf-tr-wipe-right; }
148
+ [data-transition="wipe-up"] { animation-name: vf-tr-wipe-up; }
149
+ [data-transition="wipe-down"] { animation-name: vf-tr-wipe-down; }
150
+ [data-transition="zoom-in"] { animation-name: vf-tr-zoom-in; }
151
+ [data-transition="zoom-out"] { animation-name: vf-tr-zoom-out; }
152
+ [data-transition="circle-open"] { animation-name: vf-tr-circle-open; }
153
+ `;
154
+ export function clampTransitionDuration(value) {
155
+ const next = Number(value);
156
+ if (!Number.isFinite(next) || next <= 0)
157
+ return TRANSITION_DEFAULT_DURATION;
158
+ return Math.max(TRANSITION_MIN_DURATION, Math.min(TRANSITION_MAX_DURATION, next));
159
+ }
160
+ export function normalizeTransition(value) {
161
+ if (!value)
162
+ return null;
163
+ const raw = typeof value === "string" ? { preset: value } : value;
164
+ if (!TRANSITION_PRESETS.includes(raw.preset))
165
+ return null;
166
+ return {
167
+ preset: raw.preset,
168
+ duration: raw.duration !== undefined ? clampTransitionDuration(raw.duration) : undefined
169
+ };
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Animated captions. Same declarative contract as Ken Burns: the caption layer
173
+ // <div> (the element that owns data-start/data-duration) carries
174
+ // data-caption-animation="<preset>" plus per-cue CSS vars, and each word is a
175
+ // <span data-cap-word> whose inline animation-delay/animation-duration encode
176
+ // that word's window RELATIVE TO THE LAYER START. The render producer's CSS
177
+ // adapter seeks every animation to (frameTime - ancestor data-start), and both
178
+ // preview runtimes scrub the same way, so word-level motion is deterministic
179
+ // and identical across preview and export. Everything is pure HTML+CSS so it
180
+ // survives sanitizeCompositionHtml. Word timings also round-trip through
181
+ // data-word-start/data-word-end so editors can re-derive them without parsing
182
+ // CSS.
183
+ export const CAPTION_ANIMATIONS = [
184
+ "highlight-pop",
185
+ "karaoke-fill",
186
+ "word-pop",
187
+ "cumulative-reveal",
188
+ "glow-pulse",
189
+ "bounce-wave"
190
+ ];
191
+ export const CAPTION_STYLE_MARKER = "__VF_CAPTIONS_V1__";
192
+ export const CAPTION_ANIM_CSS = `/*${CAPTION_STYLE_MARKER}*/
193
+ [data-caption-animation] [data-cap-word] {
194
+ display:inline-block;
195
+ animation-play-state:paused;
196
+ animation-timing-function:ease-out;
197
+ animation-fill-mode:none;
198
+ will-change:transform,opacity;
199
+ }
200
+ [data-caption-uppercase="1"] { text-transform:uppercase; }
201
+ @keyframes vf-cap-highlight { 14%, 100% { background-color:var(--vf-cap-hl, #7C3AED); color:var(--vf-cap-active, #FFFFFF); transform:scale(1.06); } }
202
+ [data-caption-animation="highlight-pop"] [data-cap-word] { animation-name:vf-cap-highlight; padding:0.08em 0.18em; margin:-0.08em -0.05em; border-radius:0.3em; }
203
+ @keyframes vf-cap-karaoke { to { color:var(--vf-cap-active, #FFE14D); } }
204
+ [data-caption-animation="karaoke-fill"] [data-cap-word] { animation-name:vf-cap-karaoke; animation-fill-mode:forwards; animation-timing-function:linear; }
205
+ @keyframes vf-cap-word-pop { 0% { opacity:0; transform:scale(0.45); } 22% { opacity:1; transform:scale(1.14); } 38%, 100% { opacity:1; transform:scale(1); } }
206
+ [data-caption-animation="word-pop"] [data-vf-text-inline] { display:grid; place-items:center; }
207
+ [data-caption-animation="word-pop"] [data-cap-word] { animation-name:vf-cap-word-pop; opacity:0; grid-area:1 / 1; }
208
+ @keyframes vf-cap-reveal { from { opacity:0; transform:translateY(0.4em); } 40%, 100% { opacity:1; transform:none; } }
209
+ [data-caption-animation="cumulative-reveal"] [data-cap-word] { animation-name:vf-cap-reveal; animation-fill-mode:forwards; opacity:0; }
210
+ @keyframes vf-cap-glow { 20%, 80% { color:var(--vf-cap-active, #9BFF57); text-shadow:0 0 0.14em var(--vf-cap-active, #9BFF57), 0 0 0.5em var(--vf-cap-active, #9BFF57); transform:scale(1.04); } }
211
+ [data-caption-animation="glow-pulse"] [data-cap-word] { animation-name:vf-cap-glow; }
212
+ @keyframes vf-cap-bounce { 25% { transform:translateY(-0.22em) scale(1.08); } 55% { transform:translateY(0.05em); } 75% { transform:none; } }
213
+ [data-caption-animation="bounce-wave"] [data-cap-word] { animation-name:vf-cap-bounce; }
214
+ `;
215
+ export const CAPTION_STYLE_PRESETS = [
216
+ {
217
+ id: "spotlight",
218
+ label: "Spotlight",
219
+ animation: "highlight-pop",
220
+ color: "#ffffff",
221
+ background: "#000000",
222
+ textBackgroundStyle: "outline",
223
+ highlightColor: "#7C3AED",
224
+ activeColor: "#ffffff",
225
+ fontWeight: 800,
226
+ uppercase: true
227
+ },
228
+ {
229
+ id: "karaoke",
230
+ label: "Karaoke",
231
+ animation: "karaoke-fill",
232
+ color: "#ffffff",
233
+ background: "#000000",
234
+ textBackgroundStyle: "outline",
235
+ activeColor: "#FFE14D",
236
+ fontWeight: 800
237
+ },
238
+ {
239
+ id: "word-pop",
240
+ label: "Word Pop",
241
+ animation: "word-pop",
242
+ color: "#ffffff",
243
+ background: "#000000",
244
+ textBackgroundStyle: "outline",
245
+ fontWeight: 900,
246
+ uppercase: true
247
+ },
248
+ {
249
+ id: "stack-up",
250
+ label: "Stack Up",
251
+ animation: "cumulative-reveal",
252
+ color: "#ffffff",
253
+ background: "#000000",
254
+ textBackgroundStyle: "highlight-translucent",
255
+ fontWeight: 700
256
+ },
257
+ {
258
+ id: "neon",
259
+ label: "Neon Glow",
260
+ animation: "glow-pulse",
261
+ color: "#eafff1",
262
+ background: "#04140a",
263
+ textBackgroundStyle: "plain",
264
+ activeColor: "#9BFF57",
265
+ fontWeight: 800,
266
+ uppercase: true
267
+ },
268
+ {
269
+ id: "bounce",
270
+ label: "Bounce",
271
+ animation: "bounce-wave",
272
+ color: "#FFE14D",
273
+ background: "#000000",
274
+ textBackgroundStyle: "outline",
275
+ activeColor: "#FFE14D",
276
+ fontWeight: 900,
277
+ uppercase: true
278
+ }
279
+ ];
280
+ export function captionStylePresetById(id) {
281
+ if (!id)
282
+ return null;
283
+ const clean = String(id).trim().toLowerCase();
284
+ return CAPTION_STYLE_PRESETS.find((preset) => preset.id === clean) || null;
285
+ }
286
+ export function normalizeCaptionConfig(value) {
287
+ if (!value)
288
+ return null;
289
+ const raw = typeof value === "string" ? { animation: value } : value;
290
+ if (!CAPTION_ANIMATIONS.includes(raw.animation))
291
+ return null;
292
+ const words = Array.isArray(raw.words)
293
+ ? raw.words
294
+ .map((word) => ({
295
+ text: String(word?.text ?? "").trim(),
296
+ start: Math.max(0, Number(word?.start) || 0),
297
+ end: Math.max(0, Number(word?.end) || 0)
298
+ }))
299
+ .filter((word) => word.text && word.end > word.start)
300
+ : undefined;
301
+ return {
302
+ animation: raw.animation,
303
+ words: words && words.length ? words : undefined,
304
+ activeColor: typeof raw.activeColor === "string" && raw.activeColor.trim() ? raw.activeColor.trim() : undefined,
305
+ highlightColor: typeof raw.highlightColor === "string" && raw.highlightColor.trim() ? raw.highlightColor.trim() : undefined,
306
+ uppercase: raw.uppercase === true ? true : undefined
307
+ };
308
+ }
309
+ // Char-weighted word windows across a cue when the transcript has no
310
+ // word-level timings (Gemini segments, hand-typed text). Leaves a hair of
311
+ // silence between words so pop/highlight transitions read as discrete.
312
+ export function estimateCaptionWords(text, durationSeconds) {
313
+ const tokens = String(text || "").trim().split(/\s+/).filter(Boolean);
314
+ const duration = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : tokens.length * 0.32;
315
+ if (!tokens.length)
316
+ return [];
317
+ const weights = tokens.map((token) => Math.max(2, token.length) + 1.5);
318
+ const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
319
+ const words = [];
320
+ let cursor = 0;
321
+ for (let index = 0; index < tokens.length; index += 1) {
322
+ const span = (weights[index] / totalWeight) * duration;
323
+ const gap = Math.min(0.03, span * 0.1);
324
+ words.push({
325
+ text: tokens[index],
326
+ start: Number(cursor.toFixed(3)),
327
+ end: Number(Math.max(cursor + 0.05, cursor + span - gap).toFixed(3))
328
+ });
329
+ cursor += span;
330
+ }
331
+ return words;
332
+ }
333
+ export function captionInlineVars(config) {
334
+ const vars = [];
335
+ if (config.activeColor)
336
+ vars.push(`--vf-cap-active:${config.activeColor}`);
337
+ if (config.highlightColor)
338
+ vars.push(`--vf-cap-hl:${config.highlightColor}`);
339
+ return vars.join(";");
340
+ }
78
341
  export function buildHyperframeCompositionHtml(input) {
79
342
  const duration = positiveNumber(input.duration, inferDuration(input.layers));
80
343
  const width = positiveInteger(input.width, 720);
@@ -93,6 +356,8 @@ export function buildHyperframeCompositionHtml(input) {
93
356
  [data-composition-id] { position:relative; width:100vw; height:100vh; overflow:hidden; background:${background}; }
94
357
  audio { display:none; }
95
358
  ${KEN_BURNS_CSS}
359
+ ${CAPTION_ANIM_CSS}
360
+ ${TRANSITION_CSS}
96
361
  ${input.css ?? ""}
97
362
  </style>
98
363
  </head>
@@ -118,6 +383,11 @@ export function assertHyperframeTimeline(layers) {
118
383
  throw new Error(`HyperFrame layer ${layer.id} must have a positive duration.`);
119
384
  }
120
385
  }
386
+ // Public single-layer serializer so out-of-band builders (devcli composition
387
+ // edits) emit byte-identical layer markup to the composition builder.
388
+ export function renderHyperframeLayerHtml(layer) {
389
+ return renderLayer(layer);
390
+ }
121
391
  function renderLayer(layer) {
122
392
  const attrs = timedAttrs(layer);
123
393
  const style = layerStyle(layer);
@@ -152,8 +422,39 @@ function renderLayer(layer) {
152
422
  "white-space:pre-wrap",
153
423
  "overflow:visible"
154
424
  ].join(";");
425
+ const captionConfig = layer.kind === "caption" || layer.kind === "text" ? normalizeCaptionConfig(layer.captionAnimation) : null;
426
+ if (captionConfig) {
427
+ const words = captionConfig.words ?? estimateCaptionWords(text, positiveNumber(layer.duration, 0));
428
+ if (words.length) {
429
+ const vars = captionInlineVars(captionConfig);
430
+ // text-transform must be INLINE: layerStyle already writes an inline
431
+ // text-transform:none, which would beat the [data-caption-uppercase]
432
+ // stylesheet rule. The attribute stays for round-trip state reads.
433
+ // overflow:visible (overriding layerStyle's hidden) because caption text
434
+ // wrapping past the cue box must never clip mid-glyph.
435
+ const upper = captionConfig.uppercase ? ";text-transform:uppercase" : "";
436
+ const divStyle = `${style}${vars ? `;${vars}` : ""}${upper};overflow:visible`;
437
+ const captionAttrs = `${attrs} data-caption-animation="${escapeAttr(captionConfig.animation)}"${captionConfig.uppercase ? " data-caption-uppercase=\"1\"" : ""}`;
438
+ return `<div ${captionAttrs} style="${escapeAttr(divStyle)}"><span data-vf-text-lines="true" style="${escapeAttr(linesStyle)}"><span data-vf-text-inline="true" style="${escapeAttr(inlineStyle)}">${captionWordSpansHtml(words)}</span></span></div>`;
439
+ }
440
+ }
155
441
  return `<div ${attrs} style="${escapeAttr(style)}"><span data-vf-text-lines="true" style="${escapeAttr(linesStyle)}"><span data-vf-text-inline="true" style="${escapeAttr(inlineStyle)}">${escapeHtml(text)}</span></span></div>`;
156
442
  }
443
+ // Serializes word windows into the <span data-cap-word> run that the caption
444
+ // stylesheet animates. Delay/duration are the word window relative to the
445
+ // layer start — the ONLY timing source the render adapter needs; the
446
+ // data-word-* attrs exist so editing surfaces can re-derive timings without
447
+ // parsing CSS.
448
+ export function captionWordSpansHtml(words) {
449
+ return words
450
+ .map((word) => {
451
+ const start = Math.max(0, positiveNumber(word.start, 0));
452
+ const end = Math.max(start + 0.05, positiveNumber(word.end, start + 0.05));
453
+ const wordStyle = `animation-delay:${num(start)}s;animation-duration:${num(end - start)}s`;
454
+ return `<span data-cap-word="true" data-word-start="${num(start)}" data-word-end="${num(end)}" style="${wordStyle}">${escapeHtml(word.text)}</span>`;
455
+ })
456
+ .join(" ");
457
+ }
157
458
  function timedAttrs(layer) {
158
459
  const attrs = [
159
460
  `id="${escapeAttr(layer.id)}"`,
@@ -170,6 +471,12 @@ function timedAttrs(layer) {
170
471
  const mediaStart = positiveNumber(layer.playbackStart ?? layer.mediaStart, 0);
171
472
  attrs.push(`data-media-start="${num(mediaStart)}"`, `data-playback-start="${num(mediaStart)}"`);
172
473
  }
474
+ if (layer.kind !== "audio") {
475
+ const transition = normalizeTransition(layer.transition);
476
+ if (transition) {
477
+ attrs.push(`data-transition="${escapeAttr(transition.preset)}"`, `data-transition-duration="${num(clampTransitionDuration(transition.duration ?? TRANSITION_DEFAULT_DURATION))}"`);
478
+ }
479
+ }
173
480
  if (["caption", "text", "shape", "html"].includes(layer.kind)) {
174
481
  attrs.push(`data-text-background-style="${escapeAttr(layer.textBackgroundStyle || "plain")}"`, `data-text-background-color="${escapeAttr(layer.background || "#000000")}"`, `data-font-family="${escapeAttr(layer.fontFamily || "TikTok Sans")}"`);
175
482
  }
@@ -190,6 +497,14 @@ function layerStyle(layer) {
190
497
  if (layer.kind === "video" || layer.kind === "image") {
191
498
  styles.push(`object-fit:${layer.fit || "cover"}`);
192
499
  }
500
+ if (layer.kind !== "audio") {
501
+ // data-transition picks the keyframes; the inline var supplies the span so
502
+ // the animation covers exactly the transition window.
503
+ const transition = normalizeTransition(layer.transition);
504
+ if (transition) {
505
+ styles.push(`--vf-tr-dur:${num(clampTransitionDuration(transition.duration ?? TRANSITION_DEFAULT_DURATION))}s`);
506
+ }
507
+ }
193
508
  if (["caption", "text", "shape", "html"].includes(layer.kind)) {
194
509
  const fontFamily = layer.fontFamily || "TikTok Sans";
195
510
  styles.push("display:flex", "align-items:center", "justify-content:center", "padding:3px", `font-family:${fontCssFamily(fontFamily)}, 'TikTok Sans', Montserrat, Abel, sans-serif`, `font-weight:${positiveInteger(layer.fontWeight, 700)}`, `line-height:${positiveNumber(layer.lineHeight, 1.18)}`, "text-align:center", "text-transform:none", `font-size:${positiveInteger(layer.fontSize, 32)}px`, `color:${layer.color || "#ffffff"}`, layer.kind === "shape" && layer.textBackgroundStyle === "panel"