@mevdragon/vidfarm-devcli 0.13.0 → 0.14.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.
@@ -98,10 +98,16 @@ export function normalizeKenBurns(value) {
98
98
  export const TRANSITION_PRESETS = [
99
99
  "crossfade",
100
100
  "fade-black",
101
+ "fade-white",
102
+ "flash",
103
+ "smoke",
104
+ "blur",
101
105
  "slide-left",
102
106
  "slide-right",
103
107
  "slide-up",
104
108
  "slide-down",
109
+ "whip-left",
110
+ "whip-right",
105
111
  "wipe-left",
106
112
  "wipe-right",
107
113
  "wipe-up",
@@ -110,14 +116,49 @@ export const TRANSITION_PRESETS = [
110
116
  "zoom-out",
111
117
  "circle-open"
112
118
  ];
119
+ // Exit transitions: the clip animates AWAY over its last data-transition-out-
120
+ // duration seconds. Same declarative contract as the entrance presets; the
121
+ // keyframes omit the `from` state so the animation departs from the element's
122
+ // own inline styles, and the inline --vf-tr-out-delay var (clip duration minus
123
+ // out duration) positions the animation window at the clip's tail so the
124
+ // time-based scrub in the producer and preview runtimes needs no extra logic.
125
+ export const TRANSITION_OUT_PRESETS = [
126
+ "fade",
127
+ "fade-black",
128
+ "fade-white",
129
+ "flash",
130
+ "smoke",
131
+ "blur",
132
+ "slide-left",
133
+ "slide-right",
134
+ "slide-up",
135
+ "slide-down",
136
+ "wipe-left",
137
+ "wipe-right",
138
+ "wipe-up",
139
+ "wipe-down",
140
+ "zoom-in",
141
+ "zoom-out",
142
+ "circle-close"
143
+ ];
113
144
  export const TRANSITION_DEFAULT_DURATION = 0.5;
114
145
  export const TRANSITION_MIN_DURATION = 0.1;
115
146
  export const TRANSITION_MAX_DURATION = 2.5;
116
147
  // Two butt-cut clips can drift by a few ms through timeline edits; treat ends
117
148
  // within this window as adjacent when granting the outgoing clip's hold.
118
149
  export const TRANSITION_ADJACENCY_EPSILON = 0.05;
119
- export const TRANSITION_STYLE_MARKER = "__VF_TRANSITIONS_V1__";
120
- export const TRANSITION_CSS = `/*${TRANSITION_STYLE_MARKER}*/
150
+ // V2: entrance AND exit animations compose on one element via cascading custom
151
+ // properties (--vf-tr-in-name / --vf-tr-out-name) feeding a two-slot animation
152
+ // list — per-preset attribute selectors can't merge two animation-names, vars
153
+ // can. The [data-kenburns] combo rules below fix a V1 latent clash where Ken
154
+ // Burns and a transition on the same <img> fought over animation-name (equal
155
+ // specificity, last stylesheet won); the (0,2,0) combo selectors prepend the
156
+ // vf-kb animation to the list instead.
157
+ export const TRANSITION_STYLE_MARKER = "__VF_TRANSITIONS_V2__";
158
+ // The exact V1 block previous builds baked into stored compositions. Injectors
159
+ // string-replace this with the current TRANSITION_CSS so old drafts/publishes
160
+ // upgrade in place instead of accumulating duplicate style blocks.
161
+ export const LEGACY_TRANSITION_CSS_V1 = `/*__VF_TRANSITIONS_V1__*/
121
162
  @keyframes vf-tr-crossfade { from { opacity: 0; } }
122
163
  @keyframes vf-tr-fade-black { 0% { opacity: 0; filter: brightness(0); } 35% { opacity: 1; filter: brightness(0); } }
123
164
  @keyframes vf-tr-slide-left { from { translate: 100% 0; } }
@@ -151,12 +192,119 @@ export const TRANSITION_CSS = `/*${TRANSITION_STYLE_MARKER}*/
151
192
  [data-transition="zoom-out"] { animation-name: vf-tr-zoom-out; }
152
193
  [data-transition="circle-open"] { animation-name: vf-tr-circle-open; }
153
194
  `;
195
+ export const TRANSITION_CSS = `/*${TRANSITION_STYLE_MARKER}*/
196
+ @keyframes vf-tr-crossfade { from { opacity: 0; } }
197
+ @keyframes vf-tr-fade-black { 0% { opacity: 0; filter: brightness(0); } 35% { opacity: 1; filter: brightness(0); } }
198
+ @keyframes vf-tr-fade-white { 0% { opacity: 0; filter: saturate(0.55) brightness(3); } 45% { opacity: 1; filter: saturate(0.7) brightness(2.1); } }
199
+ @keyframes vf-tr-flash { 0% { filter: saturate(0.3) brightness(5); } 55% { filter: saturate(0.9) brightness(1.6); } }
200
+ @keyframes vf-tr-smoke { 0% { opacity: 0; filter: blur(26px) saturate(0.65) brightness(1.55); } 55% { opacity: 1; } }
201
+ @keyframes vf-tr-blur { from { opacity: 0; filter: blur(16px); } }
202
+ @keyframes vf-tr-slide-left { from { translate: 100% 0; } }
203
+ @keyframes vf-tr-slide-right { from { translate: -100% 0; } }
204
+ @keyframes vf-tr-slide-up { from { translate: 0 100%; } }
205
+ @keyframes vf-tr-slide-down { from { translate: 0 -100%; } }
206
+ @keyframes vf-tr-whip-left { 0% { translate: 110% 0; filter: blur(14px); } 60% { filter: blur(5px); } }
207
+ @keyframes vf-tr-whip-right { 0% { translate: -110% 0; filter: blur(14px); } 60% { filter: blur(5px); } }
208
+ @keyframes vf-tr-wipe-left { from { clip-path: inset(0 0 0 100%); } to { clip-path: inset(0 0 0 0); } }
209
+ @keyframes vf-tr-wipe-right { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
210
+ @keyframes vf-tr-wipe-up { from { clip-path: inset(100% 0 0 0); } to { clip-path: inset(0 0 0 0); } }
211
+ @keyframes vf-tr-wipe-down { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0 0); } }
212
+ @keyframes vf-tr-zoom-in { from { opacity: 0; scale: 1.18; } }
213
+ @keyframes vf-tr-zoom-out { from { opacity: 0; scale: 0.82; } }
214
+ @keyframes vf-tr-circle-open { from { clip-path: circle(0% at 50% 50%); } to { clip-path: circle(75% at 50% 50%); } }
215
+ @keyframes vf-tr-out-fade { to { opacity: 0; } }
216
+ @keyframes vf-tr-out-fade-black { to { filter: brightness(0); } }
217
+ @keyframes vf-tr-out-fade-white { to { filter: saturate(0.35) brightness(6); } }
218
+ @keyframes vf-tr-out-flash { 65% { filter: brightness(1); } 100% { filter: saturate(0.3) brightness(5); } }
219
+ @keyframes vf-tr-out-smoke { to { opacity: 0; filter: blur(24px) saturate(0.7) brightness(1.5); } }
220
+ @keyframes vf-tr-out-blur { to { opacity: 0; filter: blur(16px); } }
221
+ @keyframes vf-tr-out-slide-left { to { translate: -100% 0; } }
222
+ @keyframes vf-tr-out-slide-right { to { translate: 100% 0; } }
223
+ @keyframes vf-tr-out-slide-up { to { translate: 0 -100%; } }
224
+ @keyframes vf-tr-out-slide-down { to { translate: 0 100%; } }
225
+ @keyframes vf-tr-out-wipe-left { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 100% 0 0); } }
226
+ @keyframes vf-tr-out-wipe-right { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 0 0 100%); } }
227
+ @keyframes vf-tr-out-wipe-up { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 0 100% 0); } }
228
+ @keyframes vf-tr-out-wipe-down { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(100% 0 0 0); } }
229
+ @keyframes vf-tr-out-zoom-in { to { opacity: 0; scale: 1.18; } }
230
+ @keyframes vf-tr-out-zoom-out { to { opacity: 0; scale: 0.82; } }
231
+ @keyframes vf-tr-out-circle-close { from { clip-path: circle(75% at 50% 50%); } to { clip-path: circle(0% at 50% 50%); } }
232
+ [data-transition], [data-transition-out] {
233
+ animation-name: var(--vf-tr-in-name, none), var(--vf-tr-out-name, none);
234
+ animation-duration: var(--vf-tr-dur, 0.5s), var(--vf-tr-out-dur, 0.5s);
235
+ animation-delay: 0s, var(--vf-tr-out-delay, 0s);
236
+ animation-timing-function: var(--vf-tr-ease, ease-in-out), var(--vf-tr-out-ease, ease-in-out);
237
+ animation-fill-mode: both, both;
238
+ animation-play-state: paused, paused;
239
+ }
240
+ [data-kenburns][data-transition], [data-kenburns][data-transition-out] {
241
+ animation-name: var(--vf-kb-name, none), var(--vf-tr-in-name, none), var(--vf-tr-out-name, none);
242
+ animation-duration: var(--vf-kb-dur, 5s), var(--vf-tr-dur, 0.5s), var(--vf-tr-out-dur, 0.5s);
243
+ animation-delay: 0s, 0s, var(--vf-tr-out-delay, 0s);
244
+ animation-timing-function: var(--vf-kb-ease, linear), var(--vf-tr-ease, ease-in-out), var(--vf-tr-out-ease, ease-in-out);
245
+ animation-fill-mode: both, both, both;
246
+ animation-play-state: paused, paused, paused;
247
+ }
248
+ [data-kenburns="zoom-in"] { --vf-kb-name: vf-kb-zoom-in; }
249
+ [data-kenburns="zoom-out"] { --vf-kb-name: vf-kb-zoom-out; }
250
+ [data-kenburns="pan-left"] { --vf-kb-name: vf-kb-pan-left; }
251
+ [data-kenburns="pan-right"] { --vf-kb-name: vf-kb-pan-right; }
252
+ [data-kenburns="pan-up"] { --vf-kb-name: vf-kb-pan-up; }
253
+ [data-kenburns="pan-down"] { --vf-kb-name: vf-kb-pan-down; }
254
+ [data-kenburns="zoom-in-left"] { --vf-kb-name: vf-kb-zoom-in-left; }
255
+ [data-kenburns="zoom-in-right"] { --vf-kb-name: vf-kb-zoom-in-right; }
256
+ [data-transition="crossfade"] { --vf-tr-in-name: vf-tr-crossfade; }
257
+ [data-transition="fade-black"] { --vf-tr-in-name: vf-tr-fade-black; }
258
+ [data-transition="fade-white"] { --vf-tr-in-name: vf-tr-fade-white; }
259
+ [data-transition="flash"] { --vf-tr-in-name: vf-tr-flash; --vf-tr-ease: cubic-bezier(0.3, 0, 0.55, 1); }
260
+ [data-transition="smoke"] { --vf-tr-in-name: vf-tr-smoke; --vf-tr-ease: cubic-bezier(0.25, 0.4, 0.3, 1); }
261
+ [data-transition="blur"] { --vf-tr-in-name: vf-tr-blur; }
262
+ [data-transition="slide-left"] { --vf-tr-in-name: vf-tr-slide-left; --vf-tr-ease: cubic-bezier(0.22, 1, 0.36, 1); }
263
+ [data-transition="slide-right"] { --vf-tr-in-name: vf-tr-slide-right; --vf-tr-ease: cubic-bezier(0.22, 1, 0.36, 1); }
264
+ [data-transition="slide-up"] { --vf-tr-in-name: vf-tr-slide-up; --vf-tr-ease: cubic-bezier(0.22, 1, 0.36, 1); }
265
+ [data-transition="slide-down"] { --vf-tr-in-name: vf-tr-slide-down; --vf-tr-ease: cubic-bezier(0.22, 1, 0.36, 1); }
266
+ [data-transition="whip-left"] { --vf-tr-in-name: vf-tr-whip-left; --vf-tr-ease: cubic-bezier(0.16, 1, 0.3, 1); }
267
+ [data-transition="whip-right"] { --vf-tr-in-name: vf-tr-whip-right; --vf-tr-ease: cubic-bezier(0.16, 1, 0.3, 1); }
268
+ [data-transition="wipe-left"] { --vf-tr-in-name: vf-tr-wipe-left; --vf-tr-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
269
+ [data-transition="wipe-right"] { --vf-tr-in-name: vf-tr-wipe-right; --vf-tr-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
270
+ [data-transition="wipe-up"] { --vf-tr-in-name: vf-tr-wipe-up; --vf-tr-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
271
+ [data-transition="wipe-down"] { --vf-tr-in-name: vf-tr-wipe-down; --vf-tr-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
272
+ [data-transition="zoom-in"] { --vf-tr-in-name: vf-tr-zoom-in; --vf-tr-ease: cubic-bezier(0.16, 1, 0.3, 1); }
273
+ [data-transition="zoom-out"] { --vf-tr-in-name: vf-tr-zoom-out; --vf-tr-ease: cubic-bezier(0.16, 1, 0.3, 1); }
274
+ [data-transition="circle-open"] { --vf-tr-in-name: vf-tr-circle-open; --vf-tr-ease: cubic-bezier(0.3, 0.6, 0.3, 1); }
275
+ [data-transition-out="fade"] { --vf-tr-out-name: vf-tr-out-fade; }
276
+ [data-transition-out="fade-black"] { --vf-tr-out-name: vf-tr-out-fade-black; }
277
+ [data-transition-out="fade-white"] { --vf-tr-out-name: vf-tr-out-fade-white; }
278
+ [data-transition-out="flash"] { --vf-tr-out-name: vf-tr-out-flash; --vf-tr-out-ease: cubic-bezier(0.45, 0, 0.7, 0.4); }
279
+ [data-transition-out="smoke"] { --vf-tr-out-name: vf-tr-out-smoke; --vf-tr-out-ease: cubic-bezier(0.7, 0, 0.75, 0.6); }
280
+ [data-transition-out="blur"] { --vf-tr-out-name: vf-tr-out-blur; }
281
+ [data-transition-out="slide-left"] { --vf-tr-out-name: vf-tr-out-slide-left; --vf-tr-out-ease: cubic-bezier(0.55, 0.06, 0.68, 0.19); }
282
+ [data-transition-out="slide-right"] { --vf-tr-out-name: vf-tr-out-slide-right; --vf-tr-out-ease: cubic-bezier(0.55, 0.06, 0.68, 0.19); }
283
+ [data-transition-out="slide-up"] { --vf-tr-out-name: vf-tr-out-slide-up; --vf-tr-out-ease: cubic-bezier(0.55, 0.06, 0.68, 0.19); }
284
+ [data-transition-out="slide-down"] { --vf-tr-out-name: vf-tr-out-slide-down; --vf-tr-out-ease: cubic-bezier(0.55, 0.06, 0.68, 0.19); }
285
+ [data-transition-out="wipe-left"] { --vf-tr-out-name: vf-tr-out-wipe-left; --vf-tr-out-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
286
+ [data-transition-out="wipe-right"] { --vf-tr-out-name: vf-tr-out-wipe-right; --vf-tr-out-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
287
+ [data-transition-out="wipe-up"] { --vf-tr-out-name: vf-tr-out-wipe-up; --vf-tr-out-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
288
+ [data-transition-out="wipe-down"] { --vf-tr-out-name: vf-tr-out-wipe-down; --vf-tr-out-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
289
+ [data-transition-out="zoom-in"] { --vf-tr-out-name: vf-tr-out-zoom-in; --vf-tr-out-ease: cubic-bezier(0.5, 0, 0.75, 0.4); }
290
+ [data-transition-out="zoom-out"] { --vf-tr-out-name: vf-tr-out-zoom-out; --vf-tr-out-ease: cubic-bezier(0.5, 0, 0.75, 0.4); }
291
+ [data-transition-out="circle-close"] { --vf-tr-out-name: vf-tr-out-circle-close; --vf-tr-out-ease: cubic-bezier(0.6, 0, 0.7, 0.4); }
292
+ `;
154
293
  export function clampTransitionDuration(value) {
155
294
  const next = Number(value);
156
295
  if (!Number.isFinite(next) || next <= 0)
157
296
  return TRANSITION_DEFAULT_DURATION;
158
297
  return Math.max(TRANSITION_MIN_DURATION, Math.min(TRANSITION_MAX_DURATION, next));
159
298
  }
299
+ // Seconds into the clip at which its exit animation must start so it finishes
300
+ // exactly at the clip's end. Written as the inline --vf-tr-out-delay var.
301
+ export function transitionOutDelaySeconds(layerDurationSeconds, outDurationSeconds) {
302
+ const layerDuration = Number(layerDurationSeconds);
303
+ const outDuration = Number(outDurationSeconds);
304
+ if (!Number.isFinite(layerDuration) || !Number.isFinite(outDuration))
305
+ return 0;
306
+ return Math.max(0, Number((layerDuration - outDuration).toFixed(3)));
307
+ }
160
308
  export function normalizeTransition(value) {
161
309
  if (!value)
162
310
  return null;
@@ -168,6 +316,28 @@ export function normalizeTransition(value) {
168
316
  duration: raw.duration !== undefined ? clampTransitionDuration(raw.duration) : undefined
169
317
  };
170
318
  }
319
+ export function normalizeTransitionOut(value) {
320
+ if (!value)
321
+ return null;
322
+ const raw = typeof value === "string" ? { preset: value } : value;
323
+ if (!TRANSITION_OUT_PRESETS.includes(raw.preset))
324
+ return null;
325
+ return {
326
+ preset: raw.preset,
327
+ duration: raw.duration !== undefined ? clampTransitionDuration(raw.duration) : undefined
328
+ };
329
+ }
330
+ // Upgrades stored composition HTML that still carries the V1 transition
331
+ // stylesheet: exact-replaces the legacy block with the current one. If the V1
332
+ // marker is present but the block has drifted, callers' normal marker-guarded
333
+ // injection appends the V2 block AFTER it, which also wins (equal-specificity
334
+ // rules, later source order) — this replace just avoids duplicate blocks in
335
+ // the common case.
336
+ export function upgradeLegacyTransitionCss(html) {
337
+ if (!html || html.includes(TRANSITION_STYLE_MARKER) || !html.includes("__VF_TRANSITIONS_V1__"))
338
+ return html;
339
+ return html.split(LEGACY_TRANSITION_CSS_V1).join(TRANSITION_CSS);
340
+ }
171
341
  // ---------------------------------------------------------------------------
172
342
  // Animated captions. Same declarative contract as Ken Burns: the caption layer
173
343
  // <div> (the element that owns data-start/data-duration) carries
@@ -476,6 +646,10 @@ function timedAttrs(layer) {
476
646
  if (transition) {
477
647
  attrs.push(`data-transition="${escapeAttr(transition.preset)}"`, `data-transition-duration="${num(clampTransitionDuration(transition.duration ?? TRANSITION_DEFAULT_DURATION))}"`);
478
648
  }
649
+ const transitionOut = normalizeTransitionOut(layer.transitionOut);
650
+ if (transitionOut) {
651
+ attrs.push(`data-transition-out="${escapeAttr(transitionOut.preset)}"`, `data-transition-out-duration="${num(clampTransitionDuration(transitionOut.duration ?? TRANSITION_DEFAULT_DURATION))}"`);
652
+ }
479
653
  }
480
654
  if (["caption", "text", "shape", "html"].includes(layer.kind)) {
481
655
  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")}"`);
@@ -504,6 +678,13 @@ function layerStyle(layer) {
504
678
  if (transition) {
505
679
  styles.push(`--vf-tr-dur:${num(clampTransitionDuration(transition.duration ?? TRANSITION_DEFAULT_DURATION))}s`);
506
680
  }
681
+ // Exit transitions additionally need the delay var so the animation window
682
+ // sits at the clip's tail (duration − out duration).
683
+ const transitionOut = normalizeTransitionOut(layer.transitionOut);
684
+ if (transitionOut) {
685
+ const outDur = clampTransitionDuration(transitionOut.duration ?? TRANSITION_DEFAULT_DURATION);
686
+ styles.push(`--vf-tr-out-dur:${num(outDur)}s`, `--vf-tr-out-delay:${num(transitionOutDelaySeconds(positiveNumber(layer.duration, 0), outDur))}s`);
687
+ }
507
688
  }
508
689
  if (["caption", "text", "shape", "html"].includes(layer.kind)) {
509
690
  const fontFamily = layer.fontFamily || "TikTok Sans";
@@ -13,7 +13,7 @@ import ffprobeStatic from "ffprobe-static";
13
13
  import { parseHTML } from "linkedom";
14
14
  import { config } from "../config.js";
15
15
  import { devErrorFields, devLog } from "../lib/dev-log.js";
16
- import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_ADJACENCY_EPSILON, TRANSITION_CSS, TRANSITION_PRESETS, TRANSITION_STYLE_MARKER, buildHyperframeCompositionHtml, clampTransitionDuration } from "../hyperframes/composition.js";
16
+ import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, LEGACY_TRANSITION_CSS_V1, TRANSITION_ADJACENCY_EPSILON, TRANSITION_CSS, TRANSITION_OUT_PRESETS, TRANSITION_PRESETS, TRANSITION_STYLE_MARKER, buildHyperframeCompositionHtml, clampTransitionDuration, transitionOutDelaySeconds } from "../hyperframes/composition.js";
17
17
  import { applyMarkupUsd } from "./billing-pricing.js";
18
18
  // The scene-annotation pass reuses the clip library's controlled vocabulary and
19
19
  // embedding-text recipe verbatim so a source scene's clip-match query is
@@ -2309,6 +2309,18 @@ function compositionHasCoverageGap(intervals, total, minGap = 0.2) {
2309
2309
  // neighbor back). Stored drafts stay butt-cut; only publish/render output
2310
2310
  // carries the overlap. The preview runtime applies the same window live.
2311
2311
  function materializeTransitionOverlaps(document) {
2312
+ const upsertStyleVar = (el, name, value) => {
2313
+ const styleValue = el.getAttribute("style") || "";
2314
+ const decl = `${name}:${value}`;
2315
+ const pattern = new RegExp(`(^|;)\\s*${name.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&")}\\s*:[^;]*`, "i");
2316
+ const nextStyle = pattern.test(styleValue)
2317
+ ? styleValue.replace(pattern, (_match, lead) => `${lead}${decl}`)
2318
+ : styleValue.length === 0 || styleValue.endsWith(";")
2319
+ ? `${styleValue}${decl}`
2320
+ : `${styleValue};${decl}`;
2321
+ if (nextStyle !== styleValue)
2322
+ el.setAttribute("style", nextStyle);
2323
+ };
2312
2324
  const clips = [];
2313
2325
  for (const el of Array.from(document.querySelectorAll("[data-start]"))) {
2314
2326
  if (el.hasAttribute("data-composition-id"))
@@ -2331,6 +2343,23 @@ function materializeTransitionOverlaps(document) {
2331
2343
  return;
2332
2344
  for (const clip of clips) {
2333
2345
  const transitionEl = clip.el;
2346
+ const tagName = transitionEl.tagName?.toLowerCase?.() ?? "";
2347
+ // Exit transitions: validate/clamp here; the tail-positioning delay var is
2348
+ // re-derived in the final loop below once holds are known (the animation
2349
+ // must end at the clip's rendered end, nominal + hold).
2350
+ const outPreset = transitionEl.getAttribute("data-transition-out");
2351
+ if (outPreset) {
2352
+ if (!TRANSITION_OUT_PRESETS.includes(outPreset) || tagName === "audio") {
2353
+ transitionEl.removeAttribute("data-transition-out");
2354
+ transitionEl.removeAttribute("data-transition-out-duration");
2355
+ }
2356
+ else {
2357
+ const outDuration = Math.min(clampTransitionDuration(transitionEl.getAttribute("data-transition-out-duration")), clip.nominal);
2358
+ const outDurationValue = Number(outDuration.toFixed(3));
2359
+ transitionEl.setAttribute("data-transition-out-duration", String(outDurationValue));
2360
+ upsertStyleVar(transitionEl, "--vf-tr-out-dur", `${outDurationValue}s`);
2361
+ }
2362
+ }
2334
2363
  const preset = transitionEl.getAttribute("data-transition");
2335
2364
  if (!preset)
2336
2365
  continue;
@@ -2341,7 +2370,6 @@ function materializeTransitionOverlaps(document) {
2341
2370
  transitionEl.removeAttribute("data-transition-duration");
2342
2371
  continue;
2343
2372
  }
2344
- const tagName = transitionEl.tagName?.toLowerCase?.() ?? "";
2345
2373
  if (tagName === "audio") {
2346
2374
  transitionEl.removeAttribute("data-transition");
2347
2375
  transitionEl.removeAttribute("data-transition-duration");
@@ -2353,15 +2381,7 @@ function materializeTransitionOverlaps(document) {
2353
2381
  transitionEl.setAttribute("data-transition-duration", String(durationValue));
2354
2382
  // Keep the inline var (which drives animation-duration) in lockstep, same
2355
2383
  // as the --vf-kb-dur re-derivation above.
2356
- const styleValue = transitionEl.getAttribute("style") || "";
2357
- const durDecl = `--vf-tr-dur:${durationValue}s`;
2358
- const nextStyle = /(^|;)\s*--vf-tr-dur\s*:[^;]*/i.test(styleValue)
2359
- ? styleValue.replace(/(^|;)\s*--vf-tr-dur\s*:[^;]*/i, (_match, lead) => `${lead}${durDecl}`)
2360
- : styleValue.length === 0 || styleValue.endsWith(";")
2361
- ? `${styleValue}${durDecl}`
2362
- : `${styleValue};${durDecl}`;
2363
- if (nextStyle !== styleValue)
2364
- transitionEl.setAttribute("style", nextStyle);
2384
+ upsertStyleVar(transitionEl, "--vf-tr-dur", `${durationValue}s`);
2365
2385
  // Grant the hold to the clip this one cuts away from: same track, nominal
2366
2386
  // end at (within epsilon of) this clip's start.
2367
2387
  for (const candidate of clips) {
@@ -2390,6 +2410,13 @@ function materializeTransitionOverlaps(document) {
2390
2410
  else {
2391
2411
  clip.el.removeAttribute("data-transition-hold");
2392
2412
  }
2413
+ // Position the exit animation at the clip's rendered tail. Timeline edits
2414
+ // touch data-duration only, so this re-derivation (like --vf-kb-dur) is
2415
+ // what keeps stored delays honest after retimes.
2416
+ if (clip.el.getAttribute("data-transition-out")) {
2417
+ const outDuration = clampTransitionDuration(clip.el.getAttribute("data-transition-out-duration"));
2418
+ upsertStyleVar(clip.el, "--vf-tr-out-delay", `${transitionOutDelaySeconds(nextDuration, outDuration)}s`);
2419
+ }
2393
2420
  }
2394
2421
  }
2395
2422
  export function normalizePublishHtml(html) {
@@ -2519,10 +2546,28 @@ export function normalizePublishHtml(html) {
2519
2546
  (document.head ?? document.documentElement)?.appendChild(styleEl);
2520
2547
  }
2521
2548
  materializeTransitionOverlaps(document);
2522
- if (document.querySelector("[data-transition]") && !html.includes(TRANSITION_STYLE_MARKER)) {
2523
- const styleEl = document.createElement("style");
2524
- styleEl.textContent = TRANSITION_CSS;
2525
- (document.head ?? document.documentElement)?.appendChild(styleEl);
2549
+ if (document.querySelector("[data-transition], [data-transition-out]") && !html.includes(TRANSITION_STYLE_MARKER)) {
2550
+ // Compositions stored before V2 carry the V1 block inline: upgrade it in
2551
+ // place (exact string swap) instead of stacking a second stylesheet. If the
2552
+ // legacy block drifted, append after it — later source order wins the
2553
+ // equal-specificity animation-name battle, so V2 still takes effect.
2554
+ let upgradedLegacy = false;
2555
+ if (html.includes("__VF_TRANSITIONS_V1__")) {
2556
+ for (const styleEl of Array.from(document.querySelectorAll("style"))) {
2557
+ const text = styleEl.textContent || "";
2558
+ if (!text.includes("__VF_TRANSITIONS_V1__"))
2559
+ continue;
2560
+ styleEl.textContent = text.includes(LEGACY_TRANSITION_CSS_V1)
2561
+ ? text.split(LEGACY_TRANSITION_CSS_V1).join(TRANSITION_CSS)
2562
+ : `${text}\n${TRANSITION_CSS}`;
2563
+ upgradedLegacy = true;
2564
+ }
2565
+ }
2566
+ if (!upgradedLegacy) {
2567
+ const styleEl = document.createElement("style");
2568
+ styleEl.textContent = TRANSITION_CSS;
2569
+ (document.head ?? document.documentElement)?.appendChild(styleEl);
2570
+ }
2526
2571
  }
2527
2572
  // Editor sessions occasionally leave inline style attributes with double
2528
2573
  // quotes inside CSS values (font-family: "TikTok Sans"). When serialized into
@@ -44,6 +44,7 @@ const METHOD_COLLECTIONS = {
44
44
  createTemplateRelease: "template_release",
45
45
  createTemplateSource: "template_source",
46
46
  createUserAttachment: "user_attachment",
47
+ updateUserAttachmentNotes: "user_attachment",
47
48
  createUserFileFolder: "user_file_folder",
48
49
  createUserTemporaryFile: "user_temporary_file",
49
50
  deleteEmailChannel: "email_channel",
@@ -797,9 +798,15 @@ class ServerlessRecordsServiceImpl {
797
798
  storageKey: input.storageKey,
798
799
  folderPath: normalizeFolder(input.folderPath),
799
800
  publicUrl: input.publicUrl ?? null,
801
+ notes: input.notes ?? null,
802
+ notesEmbedding: input.notesEmbedding ?? null,
803
+ notesEmbeddingModel: input.notesEmbeddingModel ?? null,
800
804
  createdAt: input.createdAt ?? timestamp
801
805
  });
802
806
  }
807
+ async updateUserAttachmentNotes(customerId, attachmentId, patch) {
808
+ return this.mergeByIdForCustomer("user_attachment", customerId, attachmentId, patch);
809
+ }
803
810
  async listUserAttachments(customerId) {
804
811
  return this.listByCustomer("user_attachment", customerId);
805
812
  }
@@ -307,6 +307,7 @@ export const TEMPLATE_EDITOR_SHELL_STYLES = `
307
307
 
308
308
  .vf-editor-chat-mobile-fab,
309
309
  .vf-editor-chat-minimize,
310
+ .vf-editor-chat-expand,
310
311
  .vf-editor-chat-back-button--header {
311
312
  display: none;
312
313
  }
@@ -2634,18 +2635,25 @@ export const TEMPLATE_EDITOR_SHELL_STYLES = `
2634
2635
  pointer-events: none;
2635
2636
  }
2636
2637
 
2638
+ /* Chat opens as a floating popup card anchored above the FAB; the expand
2639
+ toggle promotes it to a full-screen sheet (data-mobile-expanded). */
2637
2640
  .vf-editor-chat-thread {
2638
2641
  position: fixed;
2639
- inset: 0;
2642
+ top: auto;
2643
+ left: auto;
2644
+ right: 12px;
2645
+ bottom: calc(12px + env(safe-area-inset-bottom));
2640
2646
  z-index: 1;
2641
2647
  display: none;
2642
- width: 100%;
2643
- height: 100svh;
2644
- border-radius: 0;
2648
+ width: min(430px, calc(100vw - 24px));
2649
+ height: min(72svh, 640px);
2650
+ border: 1px solid rgba(219, 193, 122, 0.46);
2651
+ border-radius: 24px;
2652
+ overflow: hidden;
2645
2653
  background:
2646
2654
  radial-gradient(circle at top right, rgba(219, 193, 122, 0.12), transparent 30%),
2647
2655
  linear-gradient(180deg, rgba(255, 252, 247, 0.99), rgba(252, 247, 238, 0.98));
2648
- box-shadow: none;
2656
+ box-shadow: 0 30px 72px rgba(37, 45, 61, 0.34);
2649
2657
  pointer-events: auto;
2650
2658
  }
2651
2659
 
@@ -2653,6 +2661,22 @@ export const TEMPLATE_EDITOR_SHELL_STYLES = `
2653
2661
  display: flex;
2654
2662
  }
2655
2663
 
2664
+ /* Outranks the desktop brainstorm thread rule (display:grid), which has
2665
+ higher specificity than the plain .vf-editor-chat-thread hide above. */
2666
+ .vf-editor-chat-shell:not([data-mobile-open="true"]) .vf-editor-chat-thread {
2667
+ display: none;
2668
+ }
2669
+
2670
+ .vf-editor-chat-shell[data-mobile-expanded="true"] .vf-editor-chat-thread {
2671
+ inset: 0;
2672
+ width: 100%;
2673
+ height: 100svh;
2674
+ border: 0;
2675
+ border-radius: 0;
2676
+ box-shadow: none;
2677
+ }
2678
+
2679
+ .vf-editor-chat-expand,
2656
2680
  .vf-editor-chat-minimize {
2657
2681
  all: unset;
2658
2682
  display: inline-flex;
@@ -2681,10 +2705,21 @@ export const TEMPLATE_EDITOR_SHELL_STYLES = `
2681
2705
  }
2682
2706
 
2683
2707
  .vf-editor-chat-new-thread,
2708
+ .vf-editor-chat-expand,
2684
2709
  .vf-editor-chat-minimize {
2685
2710
  flex: 0 0 auto;
2686
2711
  }
2687
2712
 
2713
+ .vf-editor-chat-expand svg {
2714
+ width: 16px;
2715
+ height: 16px;
2716
+ fill: none;
2717
+ stroke: currentColor;
2718
+ stroke-width: 1.9;
2719
+ stroke-linecap: round;
2720
+ stroke-linejoin: round;
2721
+ }
2722
+
2688
2723
  .vf-editor-chat-header,
2689
2724
  .vf-editor-chat-tracer-bar,
2690
2725
  .vf-editor-chat-footer {
@@ -2701,31 +2736,59 @@ export const TEMPLATE_EDITOR_SHELL_STYLES = `
2701
2736
  max-width: 72vw;
2702
2737
  }
2703
2738
 
2739
+ /* iOS auto-zooms the viewport when focusing sub-16px inputs; the chat
2740
+ composer is the page's primary interaction on phones. */
2741
+ .vf-editor-chat-input,
2742
+ .vf-editor-upload-folder-dialog input {
2743
+ font-size: 16px;
2744
+ }
2745
+
2746
+ .vf-editor-chat-shell[data-layout="brainstorm"] .editor-chat-panel,
2704
2747
  .vf-editor-chat-shell[data-layout="brainstorm"] {
2705
- pointer-events: auto;
2748
+ background: transparent;
2706
2749
  }
2707
2750
 
2708
- .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-mobile-fab {
2709
- display: none;
2751
+ .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-brainstorm-layout {
2752
+ grid-template-columns: minmax(0, 1fr);
2710
2753
  }
2711
2754
 
2712
- /* Minimize makes no sense when the chat IS the page (/chat); Discover
2713
- re-enables it (with the FAB dock pattern) via its own scoped styles. */
2714
- .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-minimize {
2755
+ /* On narrow screens the persistent history sidebar collapses; the
2756
+ overlay history toggle in the header takes over instead. */
2757
+ .vf-editor-chat-brainstorm-layout.has-sidebar > .vf-editor-chat-history-pane {
2715
2758
  display: none;
2716
2759
  }
2717
2760
 
2718
- /* iOS auto-zooms the viewport when focusing sub-16px inputs; the chat
2719
- composer is the page's primary interaction on phones. */
2720
- .vf-editor-chat-input,
2721
- .vf-editor-upload-folder-dialog input {
2722
- font-size: 16px;
2761
+ /* Brainstorm surfaces (Discover/Library/Clips) use the same FAB + popup
2762
+ dock as everything else; the thread just lays out as a grid. */
2763
+ .vf-editor-chat-shell[data-layout="brainstorm"][data-mobile-open="true"] .vf-editor-chat-thread {
2764
+ display: grid;
2765
+ grid-template-rows: auto minmax(0, 1fr) auto;
2723
2766
  }
2724
2767
 
2725
- /* On phones the /chat page has no topbar and the sidebar (with its
2726
- Discover link) is collapsed — surface the header back button there.
2727
- Discover hides it again via its own scoped styles. */
2728
- .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-back-button--header {
2768
+ .vf-editor-chat-history-close,
2769
+ .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-history-toggle {
2770
+ display: inline-flex;
2771
+ }
2772
+
2773
+ .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-viewport {
2774
+ padding-left: 12px;
2775
+ padding-right: 12px;
2776
+ }
2777
+
2778
+ /* /chat is the one surface where the chat IS the page: no FAB, no
2779
+ minimize/expand, thread pinned full-screen, with a back-to-Discover
2780
+ escape hatch in the header (the page has no topbar on phones). */
2781
+ .is-chat-page .vf-editor-chat-shell[data-layout="brainstorm"] {
2782
+ pointer-events: auto;
2783
+ }
2784
+
2785
+ .is-chat-page .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-mobile-fab,
2786
+ .is-chat-page .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-minimize,
2787
+ .is-chat-page .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-expand {
2788
+ display: none;
2789
+ }
2790
+
2791
+ .is-chat-page .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-back-button--header {
2729
2792
  display: inline-flex;
2730
2793
  align-items: center;
2731
2794
  justify-content: center;
@@ -2739,27 +2802,12 @@ export const TEMPLATE_EDITOR_SHELL_STYLES = `
2739
2802
  color: #617087;
2740
2803
  }
2741
2804
 
2742
- .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-back-button--header svg {
2805
+ .is-chat-page .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-back-button--header svg {
2743
2806
  width: 18px;
2744
2807
  height: 18px;
2745
2808
  }
2746
2809
 
2747
- .vf-editor-chat-shell[data-layout="brainstorm"] .editor-chat-panel,
2748
- .vf-editor-chat-shell[data-layout="brainstorm"] {
2749
- background: transparent;
2750
- }
2751
-
2752
- .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-brainstorm-layout {
2753
- grid-template-columns: minmax(0, 1fr);
2754
- }
2755
-
2756
- /* On narrow screens the persistent history sidebar collapses; the
2757
- overlay history toggle in the header takes over instead. */
2758
- .vf-editor-chat-brainstorm-layout.has-sidebar > .vf-editor-chat-history-pane {
2759
- display: none;
2760
- }
2761
-
2762
- .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-thread {
2810
+ .is-chat-page .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-thread {
2763
2811
  position: relative;
2764
2812
  inset: auto;
2765
2813
  display: grid;
@@ -2767,18 +2815,9 @@ export const TEMPLATE_EDITOR_SHELL_STYLES = `
2767
2815
  width: 100%;
2768
2816
  height: 100%;
2769
2817
  min-height: 0;
2818
+ border: 0;
2770
2819
  border-radius: 0;
2771
- }
2772
-
2773
-
2774
- .vf-editor-chat-history-close,
2775
- .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-history-toggle {
2776
- display: inline-flex;
2777
- }
2778
-
2779
- .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-viewport {
2780
- padding-left: 12px;
2781
- padding-right: 12px;
2820
+ box-shadow: none;
2782
2821
  }
2783
2822
  }
2784
2823
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {