@mevdragon/vidfarm-devcli 0.18.0 → 0.19.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.
Files changed (68) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +166 -0
  2. package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
  3. package/.agents/skills/vidfarm-media/SKILL.md +43 -4
  4. package/SKILL.director.md +190 -18
  5. package/SKILL.platform.md +8 -4
  6. package/demo/dist/app.css +1 -1
  7. package/demo/dist/app.js +77 -75
  8. package/demo/dist/favicon.ico +0 -0
  9. package/dist/src/app.js +3031 -300
  10. package/dist/src/cli.js +1549 -69
  11. package/dist/src/config.js +7 -0
  12. package/dist/src/devcli/clip-store.js +29 -2
  13. package/dist/src/devcli/clips.js +55 -9
  14. package/dist/src/devcli/composition-edit.js +658 -8
  15. package/dist/src/devcli/local-backend.js +123 -0
  16. package/dist/src/devcli/migrate-local.js +140 -0
  17. package/dist/src/devcli/sync.js +311 -0
  18. package/dist/src/devcli/timeline-edit.js +490 -0
  19. package/dist/src/editor-chat.js +56 -17
  20. package/dist/src/frontend/discover-client.js +130 -0
  21. package/dist/src/frontend/discover-store.js +23 -0
  22. package/dist/src/frontend/file-directory.js +995 -0
  23. package/dist/src/frontend/homepage-view.js +3 -3
  24. package/dist/src/frontend/template-editor-chat.js +28 -22
  25. package/dist/src/landing-page.js +24 -7
  26. package/dist/src/page-shell.js +26 -2
  27. package/dist/src/primitive-registry.js +409 -4
  28. package/dist/src/reskin/agency-page.js +1 -1
  29. package/dist/src/reskin/calendar-page.js +2 -1
  30. package/dist/src/reskin/chat-page.js +420 -85
  31. package/dist/src/reskin/discover-page.js +731 -39
  32. package/dist/src/reskin/document.js +1311 -387
  33. package/dist/src/reskin/help-page.js +1 -0
  34. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  35. package/dist/src/reskin/inpaint-page.js +2459 -446
  36. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  37. package/dist/src/reskin/library-page.js +1168 -228
  38. package/dist/src/reskin/login-page.js +6 -6
  39. package/dist/src/reskin/pricing-page.js +2 -0
  40. package/dist/src/reskin/settings-page.js +55 -10
  41. package/dist/src/reskin/theme.js +365 -20
  42. package/dist/src/services/billing.js +4 -0
  43. package/dist/src/services/clip-curation/gemini.js +5 -0
  44. package/dist/src/services/clip-curation/hunt.js +81 -1
  45. package/dist/src/services/clip-curation/index.js +2 -1
  46. package/dist/src/services/clip-curation/local-agent.js +4 -3
  47. package/dist/src/services/clip-curation/media-select.js +85 -0
  48. package/dist/src/services/clip-curation/query.js +5 -1
  49. package/dist/src/services/clip-curation/refine.js +50 -20
  50. package/dist/src/services/clip-curation/scan.js +10 -3
  51. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  52. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  53. package/dist/src/services/clip-records.js +42 -1
  54. package/dist/src/services/clip-search.js +43 -13
  55. package/dist/src/services/file-directory.js +117 -0
  56. package/dist/src/services/hyperframes.js +283 -3
  57. package/dist/src/services/serverless-records.js +43 -0
  58. package/dist/src/services/storage.js +47 -2
  59. package/dist/src/services/upstream.js +5 -5
  60. package/dist/src/template-editor-shell.js +16 -2
  61. package/package.json +1 -1
  62. package/public/assets/discover-client-app.js +1 -0
  63. package/public/assets/file-directory-app.js +2 -0
  64. package/public/assets/homepage-client-app.js +12 -12
  65. package/public/assets/page-runtime-client-app.js +24 -24
  66. package/public/assets/placeholders/scene-placeholder.png +0 -0
  67. package/src/assets/favicon.ico +0 -0
  68. package/src/assets/logo-vidfarm.png +0 -0
@@ -180,20 +180,38 @@ function findNonCollidingTrack(clips, range, excludeId) {
180
180
  return track;
181
181
  }
182
182
  function buildMediaClip(document, opts, id, track, geom) {
183
- const node = document.createElement(opts.kind === "video" ? "video" : "img");
183
+ const tagName = opts.kind === "video" ? "video" : opts.kind === "audio" ? "audio" : "img";
184
+ const node = document.createElement(tagName);
184
185
  node.id = id;
185
186
  node.setAttribute("data-hf-id", id);
186
- node.setAttribute("data-layer-mode", "publish");
187
+ node.setAttribute("data-layer-mode", opts.kind === "audio" ? "both" : "publish");
187
188
  node.setAttribute("data-layer-kind", opts.kind);
188
189
  node.setAttribute("data-viral-note", `AI generated ${opts.kind} (devcli).`);
189
190
  node.setAttribute("data-start", String(round3(geom.start)));
190
191
  node.setAttribute("data-duration", String(round3(geom.duration)));
191
192
  node.setAttribute("data-end", String(round3(geom.start + geom.duration)));
192
193
  node.setAttribute("data-track-index", String(track));
193
- node.setAttribute("data-label", opts.label || (opts.kind === "video" ? "AI video" : "AI image"));
194
+ node.setAttribute("data-label", opts.label || (opts.kind === "video" ? "AI video" : opts.kind === "audio" ? "Audio track" : "AI image"));
194
195
  node.className = "clip";
195
196
  if (opts.slug)
196
197
  node.setAttribute("data-hf-slug", opts.slug);
198
+ // Audio is a geometry-free overlay layer: a bare <audio> mirroring the web
199
+ // editor's renderLayer output (data-timeline-role / data-volume, framework-
200
+ // managed playback, no CSS box). Multiple audio layers on distinct tracks are
201
+ // mixed together at render, each at its own volume — so this is how the
202
+ // devcli overlays narration + a low-volume music bed + SFX as separate tracks.
203
+ if (opts.kind === "audio") {
204
+ node.setAttribute("src", opts.src);
205
+ node.setAttribute("preload", "metadata");
206
+ node.setAttribute("data-timeline-role", "music");
207
+ node.setAttribute("data-volume", String(opts.volume ?? 1));
208
+ if (opts.muted === true || opts.volume === 0)
209
+ node.setAttribute("muted", "");
210
+ const ps = String(round3(opts.playbackStart ?? 0));
211
+ node.setAttribute("data-media-start", ps);
212
+ node.setAttribute("data-playback-start", ps);
213
+ return node;
214
+ }
197
215
  const styles = [
198
216
  "position:absolute",
199
217
  `left:${clampPercent(geom.x)}%`,
@@ -204,6 +222,8 @@ function buildMediaClip(document, opts, id, track, geom) {
204
222
  "overflow:hidden",
205
223
  `object-fit:${opts.objectFit || "cover"}`
206
224
  ];
225
+ if (opts.objectPosition?.trim())
226
+ styles.push(`object-position:${opts.objectPosition.trim()}`);
207
227
  node.setAttribute("src", opts.src);
208
228
  if (opts.kind === "video") {
209
229
  node.setAttribute("playsinline", "");
@@ -258,7 +278,10 @@ export function insertMediaLayer(html, opts) {
258
278
  const dur = Number.isFinite(totalDuration) ? totalDuration : 0;
259
279
  const start = Math.max(0, dur > 0 ? Math.min(dur - 0.1, opts.start ?? 0) : (opts.start ?? 0));
260
280
  const remaining = dur > 0 ? Math.max(0.1, dur - start) : 4;
261
- const duration = Math.max(0.1, dur > 0 ? Math.min(opts.duration ?? Math.min(4, remaining), remaining) : (opts.duration ?? 4));
281
+ // Audio beds (narration/music/SFX) default to spanning the rest of the
282
+ // timeline from their start; visual clips default to a 4s slot.
283
+ const defaultDuration = opts.kind === "audio" ? remaining : Math.min(4, remaining);
284
+ const duration = Math.max(0.1, dur > 0 ? Math.min(opts.duration ?? defaultDuration, remaining) : (opts.duration ?? 4));
262
285
  const providedKey = opts.layerKey?.trim();
263
286
  const genKey = providedKey && /^[A-Za-z][\w-]{0,63}$/.test(providedKey)
264
287
  ? providedKey
@@ -364,10 +387,12 @@ export function insertCaptionLayers(html, cues, opts = {}) {
364
387
  duration: cue.duration,
365
388
  track,
366
389
  z: track,
367
- x: opts.x ?? CAPTION_DEFAULT_FRAME.x,
368
- y: opts.y ?? CAPTION_DEFAULT_FRAME.y,
369
- width: opts.width ?? CAPTION_DEFAULT_FRAME.width,
370
- height: opts.height ?? CAPTION_DEFAULT_FRAME.height,
390
+ x: typeof opts.x === "number" && Number.isFinite(opts.x) ? opts.x : CAPTION_DEFAULT_FRAME.x,
391
+ y: typeof opts.y === "number" && Number.isFinite(opts.y) ? opts.y : CAPTION_DEFAULT_FRAME.y,
392
+ // A 0 (or missing) width/height would render the caption invisibly — treat
393
+ // only a positive number as an intentional override, else use the default.
394
+ width: typeof opts.width === "number" && opts.width > 0 ? opts.width : CAPTION_DEFAULT_FRAME.width,
395
+ height: typeof opts.height === "number" && opts.height > 0 ? opts.height : CAPTION_DEFAULT_FRAME.height,
371
396
  text: cue.text,
372
397
  color: look.color,
373
398
  background: look.background,
@@ -512,6 +537,27 @@ function setNodeTransitionOut(node, preset, durationSeconds) {
512
537
  upsertStyleDecl(node, "--vf-tr-out-dur", `${round3(duration)}s`);
513
538
  upsertStyleDecl(node, "--vf-tr-out-delay", `${transitionOutDelaySeconds(Number.isFinite(layerDuration) ? layerDuration : 0, duration)}s`);
514
539
  }
540
+ // In-place Ken Burns (still-image pan/zoom) toggle on an EXISTING node — mirrors
541
+ // buildMediaClip's ken-burns block (data-kenburns + --vf-kb-dur + --vf-kb-amount)
542
+ // and the web applyKenBurnsToNode. "none"/null clears it.
543
+ function setNodeKenBurns(node, preset, intensity) {
544
+ if (!preset || preset === "none") {
545
+ node.removeAttribute("data-kenburns");
546
+ upsertStyleDecl(node, "--vf-kb-dur", null);
547
+ upsertStyleDecl(node, "--vf-kb-amount", null);
548
+ return;
549
+ }
550
+ if (!KEN_BURNS_PRESETS.has(preset)) {
551
+ throw new Error(`Unknown ken-burns preset "${preset}". Use one of: none, ${Array.from(KEN_BURNS_PRESETS).join(", ")}.`);
552
+ }
553
+ const dur = numAttr(node, "data-duration");
554
+ node.setAttribute("data-kenburns", preset);
555
+ if (Number.isFinite(dur) && dur > 0)
556
+ upsertStyleDecl(node, "--vf-kb-dur", `${round3(dur)}s`);
557
+ if (intensity !== undefined && Number.isFinite(intensity)) {
558
+ upsertStyleDecl(node, "--vf-kb-amount", String(Math.max(0.04, Math.min(0.5, intensity))));
559
+ }
560
+ }
515
561
  // Bulk pass over the whole timeline — the devcli twin of the AI's
516
562
  // set_transitions action and the editor's junction chips.
517
563
  export function applyTransitionsAcross(html, opts) {
@@ -606,4 +652,608 @@ export function readTransitions(html) {
606
652
  };
607
653
  }).sort((a, b) => (a.start - b.start) || (a.track - b.track));
608
654
  }
655
+ const KEYFRAME_EASINGS = ["linear", "ease", "ease-in", "ease-out", "ease-in-out", "step-start", "step-end"];
656
+ // Whitelist the CSS timing-function so an easing can never smuggle arbitrary
657
+ // declarations into the injected <style>/inline style. Mirrors the editor's
658
+ // normalizeKeyframeEasing exactly.
659
+ function normalizeKeyframeEasing(raw) {
660
+ const value = (raw ?? "").trim().toLowerCase();
661
+ if (KEYFRAME_EASINGS.includes(value))
662
+ return value;
663
+ const cb = /^cubic-bezier\(\s*(-?\d*\.?\d+\s*,\s*){3}-?\d*\.?\d+\s*\)$/;
664
+ if (cb.test(value))
665
+ return value;
666
+ const steps = /^steps\(\s*\d+\s*(,\s*(start|end|jump-(start|end|none|both))\s*)?\)$/;
667
+ if (steps.test(value))
668
+ return value;
669
+ return "linear";
670
+ }
671
+ function nodeTimelineId(node) {
672
+ return node?.getAttribute?.("data-hf-id") || node?.id || "";
673
+ }
674
+ // Couple the timing fields the way the editor's patchElementTiming does: a start
675
+ // or duration change re-derives data-end; a track change re-derives z-index (the
676
+ // runtime/publish normalizer keeps z-index == track); a media in-point (video/
677
+ // audio) rides on data-media-start + data-playback-start together.
678
+ function applyTimingToNode(node, updates) {
679
+ let start = numAttr(node, "data-start");
680
+ let duration = numAttr(node, "data-duration");
681
+ if (updates.start !== undefined) {
682
+ start = updates.start;
683
+ node.setAttribute("data-start", String(round3(start)));
684
+ }
685
+ if (updates.duration !== undefined) {
686
+ duration = updates.duration;
687
+ node.setAttribute("data-duration", String(round3(duration)));
688
+ }
689
+ if (updates.start !== undefined || updates.duration !== undefined) {
690
+ const s = Number.isFinite(start) ? start : 0;
691
+ const d = Number.isFinite(duration) ? duration : 0;
692
+ node.setAttribute("data-end", String(round3(s + d)));
693
+ }
694
+ if (updates.track !== undefined) {
695
+ const t = Math.max(0, Math.trunc(updates.track));
696
+ node.setAttribute("data-track-index", String(t));
697
+ upsertStyleDecl(node, "z-index", String(t));
698
+ }
699
+ if (updates.playbackStart !== undefined) {
700
+ const ps = String(round3(Math.max(0, updates.playbackStart)));
701
+ node.setAttribute("data-media-start", ps);
702
+ node.setAttribute("data-playback-start", ps);
703
+ }
704
+ }
705
+ // set_layer_keyframes twin: author a script-free CSS @keyframes animation on ONE
706
+ // layer. The rule lives in a <style data-vf-kf="hfk-…"> in <head> (any prior
707
+ // block for the same layer is removed first); the layer gets an inline
708
+ // `animation: hfk-… <dur>s <easing> both paused`. The keyframe name is NEVER
709
+ // vf-* (those are reserved for kenburns/transition/caption machinery); paused +
710
+ // fill:both keeps it seek-safe in preview AND render.
711
+ export function setLayerKeyframes(html, layerKey, opts) {
712
+ const frames = (opts.keyframes ?? []).filter((f) => Number.isFinite(f.offset)).sort((a, b) => a.offset - b.offset);
713
+ if (frames.length < 2) {
714
+ throw new Error("keyframes requires at least 2 stops, each with a numeric offset in 0..1.");
715
+ }
716
+ if (frames.every((f) => f.opacity === undefined && f.translate_x === undefined && f.translate_y === undefined && f.scale === undefined && f.rotate === undefined)) {
717
+ throw new Error("each keyframe needs at least one animated property (opacity, translate_x, translate_y, scale, rotate).");
718
+ }
719
+ const { document, root } = readCompositionDoc(html);
720
+ const node = resolveLayerNode(root, layerKey);
721
+ if (!node)
722
+ throw new Error(`Layer not found: ${layerKey}`);
723
+ const baseId = nodeTimelineId(node) || "layer";
724
+ const animName = `hfk-${baseId.replace(/[^A-Za-z0-9_-]/g, "-")}`;
725
+ const clipDuration = numAttr(node, "data-duration");
726
+ const durationSec = opts.durationSec !== undefined && opts.durationSec > 0
727
+ ? opts.durationSec
728
+ : (Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 1);
729
+ const easing = normalizeKeyframeEasing(opts.easing);
730
+ const cssFrames = frames.map((f) => {
731
+ const pct = Math.max(0, Math.min(100, f.offset * 100));
732
+ const pctLabel = Number(pct.toFixed(3)).toString();
733
+ const transforms = [];
734
+ if (f.translate_x !== undefined || f.translate_y !== undefined) {
735
+ transforms.push(`translate(${f.translate_x ?? 0}%, ${f.translate_y ?? 0}%)`);
736
+ }
737
+ if (f.scale !== undefined)
738
+ transforms.push(`scale(${f.scale})`);
739
+ if (f.rotate !== undefined)
740
+ transforms.push(`rotate(${f.rotate}deg)`);
741
+ const decls = [];
742
+ if (transforms.length)
743
+ decls.push(`transform:${transforms.join(" ")}`);
744
+ if (f.opacity !== undefined)
745
+ decls.push(`opacity:${Math.max(0, Math.min(1, f.opacity))}`);
746
+ return `${pctLabel}%{${decls.join(";")}}`;
747
+ }).join("");
748
+ const keyframeCss = `@keyframes ${animName}{${cssFrames}}`;
749
+ // Replace any prior keyframe block authored for THIS layer, then inject fresh.
750
+ // animName is already restricted to [A-Za-z0-9_-] so no selector escaping needed.
751
+ for (const old of Array.from(document.querySelectorAll(`style[data-vf-kf="${animName}"]`))) {
752
+ old.parentNode?.removeChild(old);
753
+ }
754
+ const styleEl = document.createElement("style");
755
+ styleEl.setAttribute("data-vf-kf", animName);
756
+ styleEl.textContent = keyframeCss;
757
+ const head = document.querySelector("head") ?? document.body ?? document.documentElement;
758
+ head.appendChild(styleEl);
759
+ upsertStyleDecl(node, "animation", `${animName} ${round3(durationSec)}s ${easing} both paused`);
760
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, stops: frames.length, animName, durationSec: round3(durationSec), easing };
761
+ }
762
+ // nudge_layers twin: relative timeline move of one or more layers, group-aware
763
+ // (a grouped clip carries its whole data-vf-group with it). Start/track clamp to
764
+ // >= 0. Applied in one pass; collisions between the moving clips are NOT
765
+ // auto-resolved (unlike the single-layer restack) — the composition-edit helper
766
+ // only resolves one excluded node at a time.
767
+ export function nudgeLayers(html, keys, opts) {
768
+ const deltaStart = opts.deltaStart ?? 0;
769
+ const deltaTrack = opts.deltaTrack ?? 0;
770
+ if (!deltaStart && !deltaTrack) {
771
+ throw new Error("nudge needs a non-zero --delta-start (seconds) and/or --delta-track (lanes).");
772
+ }
773
+ const cleanKeys = keys.filter((k) => typeof k === "string" && k.trim().length > 0);
774
+ if (!cleanKeys.length)
775
+ throw new Error("nudge requires --layer <key> (repeatable).");
776
+ const { document, root } = readCompositionDoc(html);
777
+ const moveSet = new Map();
778
+ for (const key of cleanKeys) {
779
+ const seed = resolveLayerNode(root, key);
780
+ if (!seed)
781
+ throw new Error(`Layer not found: ${key}`);
782
+ const groupId = seed.getAttribute("data-vf-group");
783
+ if (groupId) {
784
+ const escaped = groupId.replace(/["\\]/g, "\\$&");
785
+ for (const gn of Array.from(root.querySelectorAll(`[data-vf-group="${escaped}"]`))) {
786
+ moveSet.set(nodeTimelineId(gn) || `n${moveSet.size}`, gn);
787
+ }
788
+ }
789
+ else {
790
+ moveSet.set(nodeTimelineId(seed) || `n${moveSet.size}`, seed);
791
+ }
792
+ }
793
+ for (const node of moveSet.values()) {
794
+ const start = numAttr(node, "data-start");
795
+ const track = numAttr(node, "data-track-index");
796
+ applyTimingToNode(node, {
797
+ start: Math.max(0, (Number.isFinite(start) ? start : 0) + deltaStart),
798
+ track: Math.max(0, (Number.isFinite(track) ? track : 0) + Math.trunc(deltaTrack))
799
+ });
800
+ }
801
+ return { html: serialize(document), moved: Array.from(moveSet.values()).map((n) => nodeTimelineId(n)) };
802
+ }
803
+ // ripple_edit twin: insert (delta > 0) or close (delta < 0) time at `at`,
804
+ // shifting every clip whose data-start >= at across ALL tracks so downstream
805
+ // timing stays in sync. Starts clamp to >= 0.
806
+ export function rippleEdit(html, opts) {
807
+ if (!Number.isFinite(opts.at))
808
+ throw new Error("ripple requires --at (seconds on the composition timeline).");
809
+ if (!opts.delta || !Number.isFinite(opts.delta)) {
810
+ throw new Error("ripple requires a non-zero --delta (seconds; positive inserts a gap, negative closes one).");
811
+ }
812
+ const { document, root } = readCompositionDoc(html);
813
+ const affected = collectClips(root).filter((node) => {
814
+ const s = numAttr(node, "data-start");
815
+ return Number.isFinite(s) && s >= opts.at - 0.0001;
816
+ });
817
+ if (!affected.length)
818
+ throw new Error(`No clips start at or after ${opts.at}s to ripple.`);
819
+ for (const node of affected) {
820
+ const s = numAttr(node, "data-start");
821
+ applyTimingToNode(node, { start: Math.max(0, s + opts.delta) });
822
+ }
823
+ return { html: serialize(document), affected: affected.length };
824
+ }
825
+ // trim_layer twin: move ONE edge of a clip to a composition time. edge='start'
826
+ // advances data-start, shrinks data-duration, and (video/audio only) pushes the
827
+ // media in-point forward by the same delta so the visible frame is unchanged;
828
+ // edge='end' just changes data-duration. Guards against non-positive duration.
829
+ export function trimLayer(html, layerKey, opts) {
830
+ if (opts.edge !== "start" && opts.edge !== "end")
831
+ throw new Error("trim requires --edge start|end.");
832
+ if (!Number.isFinite(opts.to))
833
+ throw new Error("trim requires --to (seconds on the composition timeline).");
834
+ const { document, root } = readCompositionDoc(html);
835
+ const node = resolveLayerNode(root, layerKey);
836
+ if (!node)
837
+ throw new Error(`Layer not found: ${layerKey}`);
838
+ const start = numAttr(node, "data-start");
839
+ const duration = numAttr(node, "data-duration");
840
+ const s = Number.isFinite(start) ? start : 0;
841
+ const d = Number.isFinite(duration) ? duration : 0;
842
+ const end = s + d;
843
+ const tag = String(node.tagName || "").toLowerCase();
844
+ const isMedia = tag === "video" || tag === "audio";
845
+ const mediaStart = numAttr(node, "data-media-start");
846
+ const ms = Number.isFinite(mediaStart) ? mediaStart : 0;
847
+ const updates = {};
848
+ if (opts.edge === "start") {
849
+ if (opts.to <= s + 0.0001) {
850
+ throw new Error(`Left trim --to (${opts.to}s) must be after the clip start (${s}s). Use \`move\` to shift the clip earlier.`);
851
+ }
852
+ if (opts.to >= end - 0.05) {
853
+ throw new Error("Left trim would leave no duration — pick a --to before the clip's end.");
854
+ }
855
+ const delta = opts.to - s;
856
+ updates.start = opts.to;
857
+ updates.duration = d - delta;
858
+ if (isMedia)
859
+ updates.playbackStart = ms + delta;
860
+ }
861
+ else {
862
+ if (opts.to <= s + 0.05) {
863
+ throw new Error(`Right trim --to (${opts.to}s) must be after the clip start (${s}s).`);
864
+ }
865
+ updates.duration = opts.to - s;
866
+ }
867
+ applyTimingToNode(node, updates);
868
+ const report = {};
869
+ if (updates.start !== undefined)
870
+ report.start = round3(updates.start);
871
+ if (updates.duration !== undefined)
872
+ report.duration = round3(updates.duration);
873
+ if (updates.playbackStart !== undefined)
874
+ report.playback_start = round3(updates.playbackStart);
875
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, updates: report };
876
+ }
877
+ // set_layer_zindex twin: restack a layer. Stacking == data-track-index (higher
878
+ // draws on top). front = maxTrack+1, back = max(0,minTrack-1), forward = track+1,
879
+ // backward = max(0,track-1); an explicit --track wins. The single-node collision
880
+ // resolver (findNonCollidingTrack) re-lanes upward when the target collides, so
881
+ // 'back'/'backward' are best-effort (two time-overlapping clips can't share a lane).
882
+ export function restackLayer(html, layerKey, opts) {
883
+ if (!opts.order && opts.track === undefined) {
884
+ throw new Error("restack needs --order front|back|forward|backward or an explicit --track.");
885
+ }
886
+ const { document, root } = readCompositionDoc(html);
887
+ const node = resolveLayerNode(root, layerKey);
888
+ if (!node)
889
+ throw new Error(`Layer not found: ${layerKey}`);
890
+ const clips = collectClips(root);
891
+ const tracks = clips.map((n) => numAttr(n, "data-track-index")).filter((t) => Number.isFinite(t));
892
+ const currentTrack = numAttr(node, "data-track-index");
893
+ const ct = Number.isFinite(currentTrack) ? currentTrack : 0;
894
+ const maxTrack = tracks.length ? Math.max(...tracks) : ct;
895
+ const minTrack = tracks.length ? Math.min(...tracks) : ct;
896
+ let targetTrack;
897
+ if (opts.track !== undefined)
898
+ targetTrack = Math.max(0, Math.trunc(opts.track));
899
+ else if (opts.order === "front")
900
+ targetTrack = maxTrack + 1;
901
+ else if (opts.order === "back")
902
+ targetTrack = Math.max(0, minTrack - 1);
903
+ else if (opts.order === "forward")
904
+ targetTrack = ct + 1;
905
+ else
906
+ targetTrack = Math.max(0, ct - 1);
907
+ const excludeId = nodeTimelineId(node);
908
+ const start = numAttr(node, "data-start");
909
+ const dur = numAttr(node, "data-duration");
910
+ const resolvedTrack = findNonCollidingTrack(clips, { start: Number.isFinite(start) ? start : 0, duration: Number.isFinite(dur) ? dur : 0, track: targetTrack }, excludeId);
911
+ if (resolvedTrack === ct) {
912
+ throw new Error(`${excludeId || layerKey} is already at track ${ct} — no stacking change.`);
913
+ }
914
+ applyTimingToNode(node, { track: resolvedTrack });
915
+ return { html: serialize(document), layerKey: excludeId || layerKey, track: resolvedTrack, order: opts.order ?? "explicit" };
916
+ }
917
+ // ---------------------------------------------------------------------------
918
+ // Named layer-edit primitives — the devcli twins of the web editor_action verbs
919
+ // set_layer_text / set_layer_style / set_layer_visual / set_layer_identity /
920
+ // duplicate_layer / split_layer / set_layer_timing, plus set_composition. All
921
+ // follow the same seams as the timeline verbs above: readCompositionDoc →
922
+ // resolveLayerNode → mutate via upsertStyleDecl (inline style, seek-safe) +
923
+ // data-* attrs → serialize. Static look = inline style; timing = data-* attrs;
924
+ // caption/text background = data-text-background-*; identity = data-hf-slug/note.
925
+ /** Rewrite a text/caption layer's visible words, preserving the caption
926
+ * word-span/outline wrapper (data-vf-text-inline) when present. */
927
+ export function setLayerText(html, layerKey, text) {
928
+ const { document, root } = readCompositionDoc(html);
929
+ const node = resolveLayerNode(root, layerKey);
930
+ if (!node)
931
+ throw new Error(`Layer not found: ${layerKey}`);
932
+ const inline = node.querySelector?.("[data-vf-text-inline]");
933
+ if (inline)
934
+ inline.textContent = text;
935
+ else
936
+ node.textContent = text;
937
+ node.setAttribute("data-label", text.slice(0, 40));
938
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey };
939
+ }
940
+ /** Restyle a layer: typography, colours, and static appearance. Mirrors the web
941
+ * set_layer_style (+ the opacity lever from set_layer_visual). Text background
942
+ * is attr-driven (data-text-background-*) like the caption renderer expects;
943
+ * everything else is inline CSS via upsertStyleDecl. */
944
+ export function setLayerStyle(html, layerKey, opts) {
945
+ const { document, root } = readCompositionDoc(html);
946
+ const node = resolveLayerNode(root, layerKey);
947
+ if (!node)
948
+ throw new Error(`Layer not found: ${layerKey}`);
949
+ const changed = [];
950
+ if (opts.color !== undefined) {
951
+ upsertStyleDecl(node, "color", opts.color);
952
+ const inline = node.querySelector?.("[data-vf-text-inline]");
953
+ if (inline)
954
+ upsertStyleDecl(inline, "color", opts.color);
955
+ changed.push("color");
956
+ }
957
+ if (opts.backgroundStyle !== undefined || opts.background !== undefined) {
958
+ const bgStyle = opts.backgroundStyle ?? node.getAttribute("data-text-background-style") ?? "plain";
959
+ const bgColor = opts.background ?? node.getAttribute("data-text-background-color") ?? "#000000";
960
+ node.setAttribute("data-text-background-style", bgStyle);
961
+ node.setAttribute("data-text-background-color", bgColor);
962
+ changed.push("background");
963
+ }
964
+ if (opts.fontFamily !== undefined) {
965
+ node.setAttribute("data-font-family", opts.fontFamily);
966
+ upsertStyleDecl(node, "font-family", `'${opts.fontFamily}', 'TikTok Sans', sans-serif`);
967
+ changed.push("font-family");
968
+ }
969
+ if (opts.fontWeight !== undefined) {
970
+ upsertStyleDecl(node, "font-weight", String(opts.fontWeight));
971
+ changed.push("font-weight");
972
+ }
973
+ if (opts.italic !== undefined) {
974
+ upsertStyleDecl(node, "font-style", opts.italic ? "italic" : "normal");
975
+ changed.push("italic");
976
+ }
977
+ if (opts.underline !== undefined) {
978
+ upsertStyleDecl(node, "text-decoration-line", opts.underline ? "underline" : "none");
979
+ changed.push("underline");
980
+ }
981
+ if (opts.textAlign !== undefined) {
982
+ upsertStyleDecl(node, "text-align", opts.textAlign);
983
+ changed.push("text-align");
984
+ }
985
+ if (opts.borderRadius !== undefined) {
986
+ upsertStyleDecl(node, "border-radius", `${opts.borderRadius}px`);
987
+ changed.push("border-radius");
988
+ }
989
+ if (opts.fontSize !== undefined) {
990
+ upsertStyleDecl(node, "font-size", `${opts.fontSize}px`);
991
+ changed.push("font-size");
992
+ }
993
+ if (opts.lineHeight !== undefined) {
994
+ upsertStyleDecl(node, "line-height", String(opts.lineHeight));
995
+ changed.push("line-height");
996
+ }
997
+ if (opts.letterSpacing !== undefined) {
998
+ upsertStyleDecl(node, "letter-spacing", `${opts.letterSpacing}px`);
999
+ changed.push("letter-spacing");
1000
+ }
1001
+ if (opts.opacity !== undefined) {
1002
+ upsertStyleDecl(node, "opacity", String(Math.max(0, Math.min(1, opts.opacity))));
1003
+ changed.push("opacity");
1004
+ }
1005
+ if (changed.length === 0)
1006
+ throw new Error("set-style needs at least one style field.");
1007
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, changed };
1008
+ }
1009
+ /** Geometry + constant opacity, mirroring the web set_layer_visual. All inline. */
1010
+ export function setLayerVisual(html, layerKey, opts) {
1011
+ const { document, root } = readCompositionDoc(html);
1012
+ const node = resolveLayerNode(root, layerKey);
1013
+ if (!node)
1014
+ throw new Error(`Layer not found: ${layerKey}`);
1015
+ const changed = [];
1016
+ if (opts.x !== undefined) {
1017
+ upsertStyleDecl(node, "left", `${clampPercent(opts.x)}%`);
1018
+ changed.push("x");
1019
+ }
1020
+ if (opts.y !== undefined) {
1021
+ upsertStyleDecl(node, "top", `${clampPercent(opts.y)}%`);
1022
+ changed.push("y");
1023
+ }
1024
+ if (opts.width !== undefined) {
1025
+ upsertStyleDecl(node, "width", `${clampPercent(opts.width)}%`);
1026
+ changed.push("width");
1027
+ }
1028
+ if (opts.height !== undefined) {
1029
+ upsertStyleDecl(node, "height", `${clampPercent(opts.height)}%`);
1030
+ changed.push("height");
1031
+ }
1032
+ if (opts.fontSize !== undefined) {
1033
+ upsertStyleDecl(node, "font-size", `${opts.fontSize}px`);
1034
+ changed.push("font-size");
1035
+ }
1036
+ if (opts.borderRadius !== undefined) {
1037
+ upsertStyleDecl(node, "border-radius", `${opts.borderRadius}px`);
1038
+ changed.push("border-radius");
1039
+ }
1040
+ if (opts.opacity !== undefined) {
1041
+ upsertStyleDecl(node, "opacity", String(Math.max(0, Math.min(1, opts.opacity))));
1042
+ changed.push("opacity");
1043
+ }
1044
+ if (changed.length === 0)
1045
+ throw new Error("set-visual needs at least one of x/y/width/height/font-size/border-radius/opacity.");
1046
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, changed };
1047
+ }
1048
+ /**
1049
+ * In-place edit of a MEDIA layer (video / image / audio): the devcli twin of the
1050
+ * web set_layer_media (applySetLayerMediaAction). Writes byte-identical
1051
+ * attributes — critically, a --src swap KEEPS the same node, key, geometry and
1052
+ * timing (unlike `place --replace`, which deletes the node and re-inserts a new
1053
+ * one with a fresh key). Subrange sugar: --source-out sets the timeline duration
1054
+ * to (source_out − in-point), exactly like the web MediaSegmentModal's in/out.
1055
+ */
1056
+ export function setLayerMedia(html, layerKey, opts) {
1057
+ const { document, root } = readCompositionDoc(html);
1058
+ const node = resolveLayerNode(root, layerKey);
1059
+ if (!node)
1060
+ throw new Error(`Layer not found: ${layerKey}`);
1061
+ const tag = String(node.tagName || "").toLowerCase();
1062
+ const kind = node.getAttribute("data-layer-kind") || (tag === "img" ? "image" : tag);
1063
+ if (tag !== "video" && tag !== "audio" && tag !== "img") {
1064
+ throw new Error(`set-media targets a media layer (video/image/audio); "${layerKey}" is a ${kind || tag} layer. Use set-text / set-style / set-visual instead.`);
1065
+ }
1066
+ const changed = [];
1067
+ if (opts.src !== undefined) {
1068
+ const src = opts.src.trim();
1069
+ if (!src)
1070
+ throw new Error("set-media --src cannot be empty.");
1071
+ node.setAttribute("src", src);
1072
+ changed.push("src");
1073
+ }
1074
+ if (opts.volume !== undefined) {
1075
+ node.setAttribute("data-volume", String(opts.volume));
1076
+ changed.push("volume");
1077
+ }
1078
+ if (opts.muted !== undefined) {
1079
+ if (opts.muted)
1080
+ node.setAttribute("muted", "");
1081
+ else
1082
+ node.removeAttribute("muted");
1083
+ changed.push("muted");
1084
+ }
1085
+ if (opts.objectFit !== undefined) {
1086
+ upsertStyleDecl(node, "object-fit", opts.objectFit);
1087
+ changed.push("object-fit");
1088
+ }
1089
+ if (opts.objectPosition !== undefined) {
1090
+ upsertStyleDecl(node, "object-position", opts.objectPosition);
1091
+ changed.push("object-position");
1092
+ }
1093
+ if (opts.kenBurns !== undefined) {
1094
+ setNodeKenBurns(node, opts.kenBurns, opts.kenBurnsIntensity);
1095
+ changed.push("ken-burns");
1096
+ }
1097
+ if (opts.transition !== undefined) {
1098
+ setNodeTransition(node, opts.transition, opts.transitionDuration);
1099
+ changed.push("transition");
1100
+ }
1101
+ if (opts.transitionOut !== undefined) {
1102
+ setNodeTransitionOut(node, opts.transitionOut, opts.transitionOutDuration);
1103
+ changed.push("transition-out");
1104
+ }
1105
+ // Subrange: in-point (media-start) and optional out-point → timeline duration.
1106
+ let nextDuration = opts.duration;
1107
+ if (opts.sourceOut !== undefined) {
1108
+ const currentIn = numAttr(node, "data-media-start");
1109
+ const inPoint = opts.playbackStart !== undefined ? opts.playbackStart : (Number.isFinite(currentIn) ? currentIn : 0);
1110
+ const span = opts.sourceOut - inPoint;
1111
+ if (!(span > 0))
1112
+ throw new Error(`--source-out (${opts.sourceOut}s) must be greater than the in-point (${inPoint}s).`);
1113
+ nextDuration = span;
1114
+ }
1115
+ if (opts.playbackStart !== undefined || nextDuration !== undefined) {
1116
+ applyTimingToNode(node, {
1117
+ playbackStart: opts.playbackStart,
1118
+ duration: nextDuration !== undefined ? Math.max(0.1, nextDuration) : undefined
1119
+ });
1120
+ if (opts.playbackStart !== undefined)
1121
+ changed.push("playback-start");
1122
+ if (nextDuration !== undefined)
1123
+ changed.push("duration");
1124
+ }
1125
+ if (changed.length === 0) {
1126
+ throw new Error("set-media needs at least one of --src / --volume / --muted / --playback-start / --source-out / --duration / --object-fit / --object-position / --ken-burns / --transition / --transition-out.");
1127
+ }
1128
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, changed, kind: kind || tag };
1129
+ }
1130
+ /** Set a layer's stable slug + human note (the AI/viral-DNA handle). Pass an
1131
+ * empty string to clear a note. Mirrors the web set_layer_identity. */
1132
+ export function setLayerIdentity(html, layerKey, opts) {
1133
+ if (opts.slug === undefined && opts.note === undefined)
1134
+ throw new Error("set-identity needs --slug and/or --note.");
1135
+ const { document, root } = readCompositionDoc(html);
1136
+ const node = resolveLayerNode(root, layerKey);
1137
+ if (!node)
1138
+ throw new Error(`Layer not found: ${layerKey}`);
1139
+ if (opts.slug !== undefined) {
1140
+ const slug = opts.slug.trim();
1141
+ if (slug)
1142
+ node.setAttribute("data-hf-slug", slug);
1143
+ else
1144
+ node.removeAttribute("data-hf-slug");
1145
+ }
1146
+ if (opts.note !== undefined) {
1147
+ if (opts.note.length)
1148
+ node.setAttribute("data-hf-note", opts.note);
1149
+ else
1150
+ node.removeAttribute("data-hf-note");
1151
+ }
1152
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey };
1153
+ }
1154
+ /** Clone a layer, offsetting its start so the copy doesn't sit exactly on top,
1155
+ * and resolving to a collision-free track. Mirrors the web duplicate_layer. */
1156
+ export function duplicateLayer(html, layerKey, opts = {}) {
1157
+ const { document, root } = readCompositionDoc(html);
1158
+ const node = resolveLayerNode(root, layerKey);
1159
+ if (!node)
1160
+ throw new Error(`Layer not found: ${layerKey}`);
1161
+ const clips = collectClips(root);
1162
+ const start = numAttr(node, "data-start");
1163
+ const duration = numAttr(node, "data-duration");
1164
+ const track = numAttr(node, "data-track-index");
1165
+ const s = Number.isFinite(start) ? start : 0;
1166
+ const d = Number.isFinite(duration) ? duration : 0.1;
1167
+ const clone = node.cloneNode(true);
1168
+ const providedKey = opts.newKey?.trim();
1169
+ const genKey = providedKey && /^[A-Za-z][\w-]{0,63}$/.test(providedKey) ? providedKey : `vf-dup-${Date.now().toString(36)}`;
1170
+ const newId = genKey.startsWith("element_") ? genKey : `element_${genKey}`;
1171
+ clone.id = newId;
1172
+ clone.setAttribute("data-hf-id", newId);
1173
+ const newStart = opts.start !== undefined && Number.isFinite(opts.start) ? Math.max(0, opts.start) : s + d;
1174
+ const initialTrack = opts.track !== undefined && Number.isFinite(opts.track) ? Math.max(0, Math.floor(opts.track)) : (Number.isFinite(track) ? track : 0);
1175
+ const resolvedTrack = findNonCollidingTrack(clips, { start: newStart, duration: d, track: initialTrack }, newId);
1176
+ applyTimingToNode(clone, { start: newStart, duration: d, track: resolvedTrack });
1177
+ root.append(clone);
1178
+ return { html: serialize(document), layerKey: newId };
1179
+ }
1180
+ /** Cut a clip in two at splitTime (composition seconds). The tail inherits the
1181
+ * media in-point offset so video/audio stays continuous. Mirrors split_layer. */
1182
+ export function splitLayer(html, layerKey, splitTime) {
1183
+ const { document, root } = readCompositionDoc(html);
1184
+ const node = resolveLayerNode(root, layerKey);
1185
+ if (!node)
1186
+ throw new Error(`Layer not found: ${layerKey}`);
1187
+ const start = numAttr(node, "data-start");
1188
+ const duration = numAttr(node, "data-duration");
1189
+ const s = Number.isFinite(start) ? start : 0;
1190
+ const d = Number.isFinite(duration) ? duration : 0;
1191
+ if (!(splitTime > s + 0.001 && splitTime < s + d - 0.001)) {
1192
+ throw new Error(`--at ${splitTime}s must fall strictly inside the clip (${round3(s)}–${round3(s + d)}s).`);
1193
+ }
1194
+ const tail = node.cloneNode(true);
1195
+ const tailId = `element_vf-split-${Date.now().toString(36)}`;
1196
+ tail.id = tailId;
1197
+ tail.setAttribute("data-hf-id", tailId);
1198
+ // First half: trim end to the split point.
1199
+ applyTimingToNode(node, { duration: splitTime - s });
1200
+ // Tail: starts at split, remaining duration, media in-point advanced by the cut.
1201
+ const priorMediaStart = numAttr(node, "data-media-start");
1202
+ const tailMediaStart = (Number.isFinite(priorMediaStart) ? priorMediaStart : 0) + (splitTime - s);
1203
+ applyTimingToNode(tail, { start: splitTime, duration: s + d - splitTime, playbackStart: tailMediaStart });
1204
+ root.append(tail);
1205
+ return { html: serialize(document), firstKey: nodeTimelineId(node) || layerKey, secondKey: tailId };
1206
+ }
1207
+ /** Absolute set of a layer's timing (start/duration/track/playback-start),
1208
+ * resolving track collisions. The devcli twin of the web set_layer_timing —
1209
+ * unlike trim (edges) or nudge (relative), this sets values directly. */
1210
+ export function setLayerTiming(html, layerKey, opts) {
1211
+ if (opts.start === undefined && opts.duration === undefined && opts.track === undefined && opts.playbackStart === undefined) {
1212
+ throw new Error("retime needs at least one of --start/--duration/--track/--playback-start.");
1213
+ }
1214
+ const { document, root } = readCompositionDoc(html);
1215
+ const node = resolveLayerNode(root, layerKey);
1216
+ if (!node)
1217
+ throw new Error(`Layer not found: ${layerKey}`);
1218
+ const clips = collectClips(root);
1219
+ const excludeId = nodeTimelineId(node);
1220
+ const nextStart = opts.start !== undefined ? Math.max(0, opts.start) : numAttr(node, "data-start");
1221
+ const nextDur = opts.duration !== undefined ? Math.max(0.1, opts.duration) : numAttr(node, "data-duration");
1222
+ const wantTrack = opts.track !== undefined ? Math.max(0, Math.floor(opts.track)) : numAttr(node, "data-track-index");
1223
+ const resolvedTrack = findNonCollidingTrack(clips, { start: Number.isFinite(nextStart) ? nextStart : 0, duration: Number.isFinite(nextDur) ? nextDur : 0.1, track: Number.isFinite(wantTrack) ? wantTrack : 0 }, excludeId);
1224
+ applyTimingToNode(node, {
1225
+ start: opts.start !== undefined ? Math.max(0, opts.start) : undefined,
1226
+ duration: opts.duration !== undefined ? Math.max(0.1, opts.duration) : undefined,
1227
+ track: resolvedTrack,
1228
+ playbackStart: opts.playbackStart
1229
+ });
1230
+ return { html: serialize(document), layerKey: excludeId || layerKey, track: resolvedTrack };
1231
+ }
1232
+ /** Composition-level (canvas / theme) edit: resize the frame, retarget total
1233
+ * render length, or recolour the canvas background. Written on the
1234
+ * [data-composition-id] root per the hyperframes contract (data-width/height/
1235
+ * duration + a background colour). The devcli twin of the web set_composition. */
1236
+ export function setComposition(html, opts) {
1237
+ const { document, root } = readCompositionDoc(html);
1238
+ const changed = [];
1239
+ if (opts.width !== undefined && Number.isFinite(opts.width) && opts.width > 0) {
1240
+ root.setAttribute("data-width", String(Math.round(opts.width)));
1241
+ changed.push(`width=${Math.round(opts.width)}`);
1242
+ }
1243
+ if (opts.height !== undefined && Number.isFinite(opts.height) && opts.height > 0) {
1244
+ root.setAttribute("data-height", String(Math.round(opts.height)));
1245
+ changed.push(`height=${Math.round(opts.height)}`);
1246
+ }
1247
+ if (opts.duration !== undefined && Number.isFinite(opts.duration) && opts.duration > 0) {
1248
+ root.setAttribute("data-duration", String(round3(opts.duration)));
1249
+ changed.push(`duration=${round3(opts.duration)}`);
1250
+ }
1251
+ if (opts.backgroundColor !== undefined && opts.backgroundColor.trim()) {
1252
+ upsertStyleDecl(root, "background", opts.backgroundColor.trim());
1253
+ changed.push(`background=${opts.backgroundColor.trim()}`);
1254
+ }
1255
+ if (changed.length === 0)
1256
+ throw new Error("set-composition needs at least one of --width/--height/--duration/--background.");
1257
+ return { html: serialize(document), changed };
1258
+ }
609
1259
  //# sourceMappingURL=composition-edit.js.map