@mevdragon/vidfarm-devcli 0.17.0 → 0.18.1
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.
- package/.agents/skills/vidfarm-media/SKILL.md +43 -6
- package/SKILL.director.md +37 -23
- package/SKILL.platform.md +6 -6
- package/demo/dist/app.js +69 -69
- package/demo/dist/favicon.ico +0 -0
- package/dist/src/app.js +1832 -213
- package/dist/src/cli.js +238 -17
- package/dist/src/devcli/clip-store.js +29 -2
- package/dist/src/devcli/clips.js +116 -73
- package/dist/src/devcli/composition-edit.js +262 -0
- package/dist/src/devcli/timeline-edit.js +283 -0
- package/dist/src/editor-chat.js +29 -6
- package/dist/src/frontend/discover-client.js +130 -0
- package/dist/src/frontend/discover-store.js +23 -0
- package/dist/src/frontend/file-directory.js +744 -0
- package/dist/src/frontend/template-editor-chat.js +22 -19
- package/dist/src/landing-page.js +24 -7
- package/dist/src/page-shell.js +25 -1
- package/dist/src/reskin/agency-page.js +214 -170
- package/dist/src/reskin/calendar-page.js +503 -187
- package/dist/src/reskin/chat-page.js +739 -299
- package/dist/src/reskin/discover-page.js +1450 -279
- package/dist/src/reskin/document.js +1392 -16
- package/dist/src/reskin/help-page.js +139 -100
- package/dist/src/reskin/index-page.js +1 -1
- package/dist/src/reskin/inpaint-page.js +547 -0
- package/dist/src/reskin/job-runs-page.js +355 -127
- package/dist/src/reskin/library-page.js +1188 -317
- package/dist/src/reskin/login-page.js +124 -87
- package/dist/src/reskin/pricing-page.js +197 -166
- package/dist/src/reskin/settings-page.js +566 -187
- package/dist/src/reskin/theme.js +434 -17
- package/dist/src/services/clip-curation/gemini.js +5 -0
- package/dist/src/services/clip-curation/hunt.js +79 -1
- package/dist/src/services/clip-curation/index.js +2 -1
- package/dist/src/services/clip-curation/local-agent.js +4 -3
- package/dist/src/services/clip-curation/media-select.js +85 -0
- package/dist/src/services/clip-curation/query.js +5 -1
- package/dist/src/services/clip-curation/refine.js +50 -20
- package/dist/src/services/clip-curation/scan.js +10 -3
- package/dist/src/services/clip-curation/taxonomy.js +3 -1
- package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
- package/dist/src/services/clip-records.js +14 -1
- package/dist/src/services/clip-search.js +43 -13
- package/dist/src/services/file-directory.js +114 -0
- package/dist/src/services/storage.js +24 -1
- package/dist/src/services/upstream.js +5 -5
- package/dist/src/template-editor-shell.js +16 -2
- package/package.json +1 -1
- package/public/assets/discover-client-app.js +1 -0
- package/public/assets/file-directory-app.js +2 -0
- package/public/assets/homepage-client-app.js +12 -12
- package/public/assets/page-runtime-client-app.js +24 -24
- package/src/assets/favicon.ico +0 -0
- package/src/assets/logo-vidfarm.png +0 -0
|
@@ -606,4 +606,266 @@ export function readTransitions(html) {
|
|
|
606
606
|
};
|
|
607
607
|
}).sort((a, b) => (a.start - b.start) || (a.track - b.track));
|
|
608
608
|
}
|
|
609
|
+
const KEYFRAME_EASINGS = ["linear", "ease", "ease-in", "ease-out", "ease-in-out", "step-start", "step-end"];
|
|
610
|
+
// Whitelist the CSS timing-function so an easing can never smuggle arbitrary
|
|
611
|
+
// declarations into the injected <style>/inline style. Mirrors the editor's
|
|
612
|
+
// normalizeKeyframeEasing exactly.
|
|
613
|
+
function normalizeKeyframeEasing(raw) {
|
|
614
|
+
const value = (raw ?? "").trim().toLowerCase();
|
|
615
|
+
if (KEYFRAME_EASINGS.includes(value))
|
|
616
|
+
return value;
|
|
617
|
+
const cb = /^cubic-bezier\(\s*(-?\d*\.?\d+\s*,\s*){3}-?\d*\.?\d+\s*\)$/;
|
|
618
|
+
if (cb.test(value))
|
|
619
|
+
return value;
|
|
620
|
+
const steps = /^steps\(\s*\d+\s*(,\s*(start|end|jump-(start|end|none|both))\s*)?\)$/;
|
|
621
|
+
if (steps.test(value))
|
|
622
|
+
return value;
|
|
623
|
+
return "linear";
|
|
624
|
+
}
|
|
625
|
+
function nodeTimelineId(node) {
|
|
626
|
+
return node?.getAttribute?.("data-hf-id") || node?.id || "";
|
|
627
|
+
}
|
|
628
|
+
// Couple the timing fields the way the editor's patchElementTiming does: a start
|
|
629
|
+
// or duration change re-derives data-end; a track change re-derives z-index (the
|
|
630
|
+
// runtime/publish normalizer keeps z-index == track); a media in-point (video/
|
|
631
|
+
// audio) rides on data-media-start + data-playback-start together.
|
|
632
|
+
function applyTimingToNode(node, updates) {
|
|
633
|
+
let start = numAttr(node, "data-start");
|
|
634
|
+
let duration = numAttr(node, "data-duration");
|
|
635
|
+
if (updates.start !== undefined) {
|
|
636
|
+
start = updates.start;
|
|
637
|
+
node.setAttribute("data-start", String(round3(start)));
|
|
638
|
+
}
|
|
639
|
+
if (updates.duration !== undefined) {
|
|
640
|
+
duration = updates.duration;
|
|
641
|
+
node.setAttribute("data-duration", String(round3(duration)));
|
|
642
|
+
}
|
|
643
|
+
if (updates.start !== undefined || updates.duration !== undefined) {
|
|
644
|
+
const s = Number.isFinite(start) ? start : 0;
|
|
645
|
+
const d = Number.isFinite(duration) ? duration : 0;
|
|
646
|
+
node.setAttribute("data-end", String(round3(s + d)));
|
|
647
|
+
}
|
|
648
|
+
if (updates.track !== undefined) {
|
|
649
|
+
const t = Math.max(0, Math.trunc(updates.track));
|
|
650
|
+
node.setAttribute("data-track-index", String(t));
|
|
651
|
+
upsertStyleDecl(node, "z-index", String(t));
|
|
652
|
+
}
|
|
653
|
+
if (updates.playbackStart !== undefined) {
|
|
654
|
+
const ps = String(round3(Math.max(0, updates.playbackStart)));
|
|
655
|
+
node.setAttribute("data-media-start", ps);
|
|
656
|
+
node.setAttribute("data-playback-start", ps);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// set_layer_keyframes twin: author a script-free CSS @keyframes animation on ONE
|
|
660
|
+
// layer. The rule lives in a <style data-vf-kf="hfk-…"> in <head> (any prior
|
|
661
|
+
// block for the same layer is removed first); the layer gets an inline
|
|
662
|
+
// `animation: hfk-… <dur>s <easing> both paused`. The keyframe name is NEVER
|
|
663
|
+
// vf-* (those are reserved for kenburns/transition/caption machinery); paused +
|
|
664
|
+
// fill:both keeps it seek-safe in preview AND render.
|
|
665
|
+
export function setLayerKeyframes(html, layerKey, opts) {
|
|
666
|
+
const frames = (opts.keyframes ?? []).filter((f) => Number.isFinite(f.offset)).sort((a, b) => a.offset - b.offset);
|
|
667
|
+
if (frames.length < 2) {
|
|
668
|
+
throw new Error("keyframes requires at least 2 stops, each with a numeric offset in 0..1.");
|
|
669
|
+
}
|
|
670
|
+
if (frames.every((f) => f.opacity === undefined && f.translate_x === undefined && f.translate_y === undefined && f.scale === undefined && f.rotate === undefined)) {
|
|
671
|
+
throw new Error("each keyframe needs at least one animated property (opacity, translate_x, translate_y, scale, rotate).");
|
|
672
|
+
}
|
|
673
|
+
const { document, root } = readCompositionDoc(html);
|
|
674
|
+
const node = resolveLayerNode(root, layerKey);
|
|
675
|
+
if (!node)
|
|
676
|
+
throw new Error(`Layer not found: ${layerKey}`);
|
|
677
|
+
const baseId = nodeTimelineId(node) || "layer";
|
|
678
|
+
const animName = `hfk-${baseId.replace(/[^A-Za-z0-9_-]/g, "-")}`;
|
|
679
|
+
const clipDuration = numAttr(node, "data-duration");
|
|
680
|
+
const durationSec = opts.durationSec !== undefined && opts.durationSec > 0
|
|
681
|
+
? opts.durationSec
|
|
682
|
+
: (Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 1);
|
|
683
|
+
const easing = normalizeKeyframeEasing(opts.easing);
|
|
684
|
+
const cssFrames = frames.map((f) => {
|
|
685
|
+
const pct = Math.max(0, Math.min(100, f.offset * 100));
|
|
686
|
+
const pctLabel = Number(pct.toFixed(3)).toString();
|
|
687
|
+
const transforms = [];
|
|
688
|
+
if (f.translate_x !== undefined || f.translate_y !== undefined) {
|
|
689
|
+
transforms.push(`translate(${f.translate_x ?? 0}%, ${f.translate_y ?? 0}%)`);
|
|
690
|
+
}
|
|
691
|
+
if (f.scale !== undefined)
|
|
692
|
+
transforms.push(`scale(${f.scale})`);
|
|
693
|
+
if (f.rotate !== undefined)
|
|
694
|
+
transforms.push(`rotate(${f.rotate}deg)`);
|
|
695
|
+
const decls = [];
|
|
696
|
+
if (transforms.length)
|
|
697
|
+
decls.push(`transform:${transforms.join(" ")}`);
|
|
698
|
+
if (f.opacity !== undefined)
|
|
699
|
+
decls.push(`opacity:${Math.max(0, Math.min(1, f.opacity))}`);
|
|
700
|
+
return `${pctLabel}%{${decls.join(";")}}`;
|
|
701
|
+
}).join("");
|
|
702
|
+
const keyframeCss = `@keyframes ${animName}{${cssFrames}}`;
|
|
703
|
+
// Replace any prior keyframe block authored for THIS layer, then inject fresh.
|
|
704
|
+
// animName is already restricted to [A-Za-z0-9_-] so no selector escaping needed.
|
|
705
|
+
for (const old of Array.from(document.querySelectorAll(`style[data-vf-kf="${animName}"]`))) {
|
|
706
|
+
old.parentNode?.removeChild(old);
|
|
707
|
+
}
|
|
708
|
+
const styleEl = document.createElement("style");
|
|
709
|
+
styleEl.setAttribute("data-vf-kf", animName);
|
|
710
|
+
styleEl.textContent = keyframeCss;
|
|
711
|
+
const head = document.querySelector("head") ?? document.body ?? document.documentElement;
|
|
712
|
+
head.appendChild(styleEl);
|
|
713
|
+
upsertStyleDecl(node, "animation", `${animName} ${round3(durationSec)}s ${easing} both paused`);
|
|
714
|
+
return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, stops: frames.length, animName, durationSec: round3(durationSec), easing };
|
|
715
|
+
}
|
|
716
|
+
// nudge_layers twin: relative timeline move of one or more layers, group-aware
|
|
717
|
+
// (a grouped clip carries its whole data-vf-group with it). Start/track clamp to
|
|
718
|
+
// >= 0. Applied in one pass; collisions between the moving clips are NOT
|
|
719
|
+
// auto-resolved (unlike the single-layer restack) — the composition-edit helper
|
|
720
|
+
// only resolves one excluded node at a time.
|
|
721
|
+
export function nudgeLayers(html, keys, opts) {
|
|
722
|
+
const deltaStart = opts.deltaStart ?? 0;
|
|
723
|
+
const deltaTrack = opts.deltaTrack ?? 0;
|
|
724
|
+
if (!deltaStart && !deltaTrack) {
|
|
725
|
+
throw new Error("nudge needs a non-zero --delta-start (seconds) and/or --delta-track (lanes).");
|
|
726
|
+
}
|
|
727
|
+
const cleanKeys = keys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
|
728
|
+
if (!cleanKeys.length)
|
|
729
|
+
throw new Error("nudge requires --layer <key> (repeatable).");
|
|
730
|
+
const { document, root } = readCompositionDoc(html);
|
|
731
|
+
const moveSet = new Map();
|
|
732
|
+
for (const key of cleanKeys) {
|
|
733
|
+
const seed = resolveLayerNode(root, key);
|
|
734
|
+
if (!seed)
|
|
735
|
+
throw new Error(`Layer not found: ${key}`);
|
|
736
|
+
const groupId = seed.getAttribute("data-vf-group");
|
|
737
|
+
if (groupId) {
|
|
738
|
+
const escaped = groupId.replace(/["\\]/g, "\\$&");
|
|
739
|
+
for (const gn of Array.from(root.querySelectorAll(`[data-vf-group="${escaped}"]`))) {
|
|
740
|
+
moveSet.set(nodeTimelineId(gn) || `n${moveSet.size}`, gn);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
moveSet.set(nodeTimelineId(seed) || `n${moveSet.size}`, seed);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
for (const node of moveSet.values()) {
|
|
748
|
+
const start = numAttr(node, "data-start");
|
|
749
|
+
const track = numAttr(node, "data-track-index");
|
|
750
|
+
applyTimingToNode(node, {
|
|
751
|
+
start: Math.max(0, (Number.isFinite(start) ? start : 0) + deltaStart),
|
|
752
|
+
track: Math.max(0, (Number.isFinite(track) ? track : 0) + Math.trunc(deltaTrack))
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
return { html: serialize(document), moved: Array.from(moveSet.values()).map((n) => nodeTimelineId(n)) };
|
|
756
|
+
}
|
|
757
|
+
// ripple_edit twin: insert (delta > 0) or close (delta < 0) time at `at`,
|
|
758
|
+
// shifting every clip whose data-start >= at across ALL tracks so downstream
|
|
759
|
+
// timing stays in sync. Starts clamp to >= 0.
|
|
760
|
+
export function rippleEdit(html, opts) {
|
|
761
|
+
if (!Number.isFinite(opts.at))
|
|
762
|
+
throw new Error("ripple requires --at (seconds on the composition timeline).");
|
|
763
|
+
if (!opts.delta || !Number.isFinite(opts.delta)) {
|
|
764
|
+
throw new Error("ripple requires a non-zero --delta (seconds; positive inserts a gap, negative closes one).");
|
|
765
|
+
}
|
|
766
|
+
const { document, root } = readCompositionDoc(html);
|
|
767
|
+
const affected = collectClips(root).filter((node) => {
|
|
768
|
+
const s = numAttr(node, "data-start");
|
|
769
|
+
return Number.isFinite(s) && s >= opts.at - 0.0001;
|
|
770
|
+
});
|
|
771
|
+
if (!affected.length)
|
|
772
|
+
throw new Error(`No clips start at or after ${opts.at}s to ripple.`);
|
|
773
|
+
for (const node of affected) {
|
|
774
|
+
const s = numAttr(node, "data-start");
|
|
775
|
+
applyTimingToNode(node, { start: Math.max(0, s + opts.delta) });
|
|
776
|
+
}
|
|
777
|
+
return { html: serialize(document), affected: affected.length };
|
|
778
|
+
}
|
|
779
|
+
// trim_layer twin: move ONE edge of a clip to a composition time. edge='start'
|
|
780
|
+
// advances data-start, shrinks data-duration, and (video/audio only) pushes the
|
|
781
|
+
// media in-point forward by the same delta so the visible frame is unchanged;
|
|
782
|
+
// edge='end' just changes data-duration. Guards against non-positive duration.
|
|
783
|
+
export function trimLayer(html, layerKey, opts) {
|
|
784
|
+
if (opts.edge !== "start" && opts.edge !== "end")
|
|
785
|
+
throw new Error("trim requires --edge start|end.");
|
|
786
|
+
if (!Number.isFinite(opts.to))
|
|
787
|
+
throw new Error("trim requires --to (seconds on the composition timeline).");
|
|
788
|
+
const { document, root } = readCompositionDoc(html);
|
|
789
|
+
const node = resolveLayerNode(root, layerKey);
|
|
790
|
+
if (!node)
|
|
791
|
+
throw new Error(`Layer not found: ${layerKey}`);
|
|
792
|
+
const start = numAttr(node, "data-start");
|
|
793
|
+
const duration = numAttr(node, "data-duration");
|
|
794
|
+
const s = Number.isFinite(start) ? start : 0;
|
|
795
|
+
const d = Number.isFinite(duration) ? duration : 0;
|
|
796
|
+
const end = s + d;
|
|
797
|
+
const tag = String(node.tagName || "").toLowerCase();
|
|
798
|
+
const isMedia = tag === "video" || tag === "audio";
|
|
799
|
+
const mediaStart = numAttr(node, "data-media-start");
|
|
800
|
+
const ms = Number.isFinite(mediaStart) ? mediaStart : 0;
|
|
801
|
+
const updates = {};
|
|
802
|
+
if (opts.edge === "start") {
|
|
803
|
+
if (opts.to <= s + 0.0001) {
|
|
804
|
+
throw new Error(`Left trim --to (${opts.to}s) must be after the clip start (${s}s). Use \`move\` to shift the clip earlier.`);
|
|
805
|
+
}
|
|
806
|
+
if (opts.to >= end - 0.05) {
|
|
807
|
+
throw new Error("Left trim would leave no duration — pick a --to before the clip's end.");
|
|
808
|
+
}
|
|
809
|
+
const delta = opts.to - s;
|
|
810
|
+
updates.start = opts.to;
|
|
811
|
+
updates.duration = d - delta;
|
|
812
|
+
if (isMedia)
|
|
813
|
+
updates.playbackStart = ms + delta;
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
if (opts.to <= s + 0.05) {
|
|
817
|
+
throw new Error(`Right trim --to (${opts.to}s) must be after the clip start (${s}s).`);
|
|
818
|
+
}
|
|
819
|
+
updates.duration = opts.to - s;
|
|
820
|
+
}
|
|
821
|
+
applyTimingToNode(node, updates);
|
|
822
|
+
const report = {};
|
|
823
|
+
if (updates.start !== undefined)
|
|
824
|
+
report.start = round3(updates.start);
|
|
825
|
+
if (updates.duration !== undefined)
|
|
826
|
+
report.duration = round3(updates.duration);
|
|
827
|
+
if (updates.playbackStart !== undefined)
|
|
828
|
+
report.playback_start = round3(updates.playbackStart);
|
|
829
|
+
return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, updates: report };
|
|
830
|
+
}
|
|
831
|
+
// set_layer_zindex twin: restack a layer. Stacking == data-track-index (higher
|
|
832
|
+
// draws on top). front = maxTrack+1, back = max(0,minTrack-1), forward = track+1,
|
|
833
|
+
// backward = max(0,track-1); an explicit --track wins. The single-node collision
|
|
834
|
+
// resolver (findNonCollidingTrack) re-lanes upward when the target collides, so
|
|
835
|
+
// 'back'/'backward' are best-effort (two time-overlapping clips can't share a lane).
|
|
836
|
+
export function restackLayer(html, layerKey, opts) {
|
|
837
|
+
if (!opts.order && opts.track === undefined) {
|
|
838
|
+
throw new Error("restack needs --order front|back|forward|backward or an explicit --track.");
|
|
839
|
+
}
|
|
840
|
+
const { document, root } = readCompositionDoc(html);
|
|
841
|
+
const node = resolveLayerNode(root, layerKey);
|
|
842
|
+
if (!node)
|
|
843
|
+
throw new Error(`Layer not found: ${layerKey}`);
|
|
844
|
+
const clips = collectClips(root);
|
|
845
|
+
const tracks = clips.map((n) => numAttr(n, "data-track-index")).filter((t) => Number.isFinite(t));
|
|
846
|
+
const currentTrack = numAttr(node, "data-track-index");
|
|
847
|
+
const ct = Number.isFinite(currentTrack) ? currentTrack : 0;
|
|
848
|
+
const maxTrack = tracks.length ? Math.max(...tracks) : ct;
|
|
849
|
+
const minTrack = tracks.length ? Math.min(...tracks) : ct;
|
|
850
|
+
let targetTrack;
|
|
851
|
+
if (opts.track !== undefined)
|
|
852
|
+
targetTrack = Math.max(0, Math.trunc(opts.track));
|
|
853
|
+
else if (opts.order === "front")
|
|
854
|
+
targetTrack = maxTrack + 1;
|
|
855
|
+
else if (opts.order === "back")
|
|
856
|
+
targetTrack = Math.max(0, minTrack - 1);
|
|
857
|
+
else if (opts.order === "forward")
|
|
858
|
+
targetTrack = ct + 1;
|
|
859
|
+
else
|
|
860
|
+
targetTrack = Math.max(0, ct - 1);
|
|
861
|
+
const excludeId = nodeTimelineId(node);
|
|
862
|
+
const start = numAttr(node, "data-start");
|
|
863
|
+
const dur = numAttr(node, "data-duration");
|
|
864
|
+
const resolvedTrack = findNonCollidingTrack(clips, { start: Number.isFinite(start) ? start : 0, duration: Number.isFinite(dur) ? dur : 0, track: targetTrack }, excludeId);
|
|
865
|
+
if (resolvedTrack === ct) {
|
|
866
|
+
throw new Error(`${excludeId || layerKey} is already at track ${ct} — no stacking change.`);
|
|
867
|
+
}
|
|
868
|
+
applyTimingToNode(node, { track: resolvedTrack });
|
|
869
|
+
return { html: serialize(document), layerKey: excludeId || layerKey, track: resolvedTrack, order: opts.order ?? "explicit" };
|
|
870
|
+
}
|
|
609
871
|
//# sourceMappingURL=composition-edit.js.map
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// devcli fine timeline control — the local-file twins of the browser editor's
|
|
2
|
+
// trackpad-level verbs (set_layer_keyframes / nudge_layers / ripple_edit /
|
|
3
|
+
// trim_layer / set_layer_zindex). Pure local DOM edits (linkedom) on a
|
|
4
|
+
// composition.html: no cloud, no wallet — a running `vidfarm serve` live-morphs
|
|
5
|
+
// the change into open editor tabs and `publish` pushes it like any browser
|
|
6
|
+
// edit. Same read/write/serialize idioms as `vidfarm place` / `captions` /
|
|
7
|
+
// `transitions`.
|
|
8
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
import { nudgeLayers, restackLayer, rippleEdit, setLayerKeyframes, trimLayer } from "./composition-edit.js";
|
|
12
|
+
// Convenience motion presets so `keyframes` is usable without hand-writing JSON.
|
|
13
|
+
// Each is a plain KeyframeStop[] — identical to what --keyframes '<json>' takes.
|
|
14
|
+
const KEYFRAME_PRESETS = {
|
|
15
|
+
"fade-in": [{ offset: 0, opacity: 0 }, { offset: 1, opacity: 1 }],
|
|
16
|
+
"fade-out": [{ offset: 0, opacity: 1 }, { offset: 1, opacity: 0 }],
|
|
17
|
+
"fly-up": [{ offset: 0, opacity: 0, translate_y: 40 }, { offset: 1, opacity: 1, translate_y: 0 }],
|
|
18
|
+
"fly-down": [{ offset: 0, opacity: 0, translate_y: -40 }, { offset: 1, opacity: 1, translate_y: 0 }],
|
|
19
|
+
"fly-left": [{ offset: 0, opacity: 0, translate_x: 40 }, { offset: 1, opacity: 1, translate_x: 0 }],
|
|
20
|
+
"fly-right": [{ offset: 0, opacity: 0, translate_x: -40 }, { offset: 1, opacity: 1, translate_x: 0 }],
|
|
21
|
+
"pop-in": [{ offset: 0, opacity: 0, scale: 0.6 }, { offset: 0.7, opacity: 1, scale: 1.05 }, { offset: 1, opacity: 1, scale: 1 }],
|
|
22
|
+
"zoom-in": [{ offset: 0, scale: 1 }, { offset: 1, scale: 1.15 }],
|
|
23
|
+
"spin-in": [{ offset: 0, opacity: 0, rotate: -90, scale: 0.8 }, { offset: 1, opacity: 1, rotate: 0, scale: 1 }],
|
|
24
|
+
"pulse": [{ offset: 0, scale: 1 }, { offset: 0.5, scale: 1.08 }, { offset: 1, scale: 1 }]
|
|
25
|
+
};
|
|
26
|
+
const KEYFRAMES_HELP = `vidfarm keyframes — author a script-free CSS @keyframes animation on ONE layer (local file write)
|
|
27
|
+
|
|
28
|
+
keyframes <dir|composition.html> --layer <key> --preset <name>
|
|
29
|
+
keyframes <dir|composition.html> --layer <key> --keyframes '<json>'
|
|
30
|
+
--layer <key> Target layer (data-hf-id / slug / element id) — required
|
|
31
|
+
--preset <name> Built-in motion: ${Object.keys(KEYFRAME_PRESETS).join(" | ")}
|
|
32
|
+
--keyframes '<json>' Custom stops: an array of >=2 {offset(0..1), opacity?,
|
|
33
|
+
translate_x?, translate_y?, scale?, rotate?}. translate_*
|
|
34
|
+
are percentages of the layer's OWN box, rotate is degrees.
|
|
35
|
+
e.g. --keyframes '[{"offset":0,"opacity":0,"translate_y":40},{"offset":1,"opacity":1,"translate_y":0}]'
|
|
36
|
+
--easing <fn> linear (default) | ease | ease-in | ease-out | ease-in-out
|
|
37
|
+
| cubic-bezier(...) | steps(...) (anything else → linear)
|
|
38
|
+
--duration <sec> Seconds the animation spans (default: the clip's duration)
|
|
39
|
+
--json
|
|
40
|
+
|
|
41
|
+
Writes a <style data-vf-kf="hfk-<layer>"> keyframe block + an inline
|
|
42
|
+
"animation: hfk-<layer> <dur>s <easing> both paused" on the layer. paused +
|
|
43
|
+
fill:both is seek-safe: it previews, serve-live-morphs, and bakes into the MP4
|
|
44
|
+
identically. The keyframe name is never vf-* (reserved for kenburns/transition/caption).`;
|
|
45
|
+
const MOVE_HELP = `vidfarm move — shift layers on the timeline by a relative delta (local file write)
|
|
46
|
+
|
|
47
|
+
move <dir|composition.html> --layer <key> [--layer <key> …]
|
|
48
|
+
--layer <key> Layer to move (repeatable); a grouped clip carries its
|
|
49
|
+
whole data-vf-group with it
|
|
50
|
+
--delta-start <sec> Shift start by N seconds (clamped to >= 0)
|
|
51
|
+
--delta-track <lanes> Shift track/lane by N (clamped to >= 0; higher = on top)
|
|
52
|
+
--json
|
|
53
|
+
|
|
54
|
+
Alias: move | nudge. Collisions between the moving clips are not auto-resolved
|
|
55
|
+
(use \`restack\` for single-layer lane resolution).`;
|
|
56
|
+
const RIPPLE_HELP = `vidfarm ripple — insert or close time across the whole timeline (local file write)
|
|
57
|
+
|
|
58
|
+
ripple <dir|composition.html> --at <sec> --delta <sec>
|
|
59
|
+
--at <sec> Ripple point on the composition timeline
|
|
60
|
+
--delta <sec> Shift every clip that starts at/after --at by N seconds
|
|
61
|
+
(positive inserts a gap, negative closes one; clamps >= 0)
|
|
62
|
+
--json`;
|
|
63
|
+
const TRIM_HELP = `vidfarm trim — move ONE edge of a clip to a time (local file write)
|
|
64
|
+
|
|
65
|
+
trim <dir|composition.html> --layer <key> --edge start|end --to <sec>
|
|
66
|
+
--layer <key> Clip to trim — required
|
|
67
|
+
--edge start|end 'start' advances the in-point (and, for video/audio,
|
|
68
|
+
pushes the media start so the visible frame is unchanged);
|
|
69
|
+
'end' changes the duration
|
|
70
|
+
--to <sec> New edge position on the composition timeline
|
|
71
|
+
--json`;
|
|
72
|
+
const RESTACK_HELP = `vidfarm restack — change a layer's stacking order (local file write)
|
|
73
|
+
|
|
74
|
+
restack <dir|composition.html> --layer <key> --order front|back|forward|backward
|
|
75
|
+
restack <dir|composition.html> --layer <key> --track <n>
|
|
76
|
+
--layer <key> Layer to restack — required
|
|
77
|
+
--order <o> front (top of all) | back | forward (+1) | backward (-1);
|
|
78
|
+
stacking == track index (higher draws on top)
|
|
79
|
+
--track <n> Explicit target lane (wins over --order)
|
|
80
|
+
--json
|
|
81
|
+
|
|
82
|
+
Alias: restack | zindex. 'back'/'backward' are best-effort — two time-overlapping
|
|
83
|
+
clips can't share a lane, so the resolver re-lanes upward on collision.`;
|
|
84
|
+
// Same dir-or-file resolution as `vidfarm place` / `captions` / `transitions`.
|
|
85
|
+
function resolveCompositionHtml(target) {
|
|
86
|
+
if (!target)
|
|
87
|
+
throw new Error("Provide the composition dir or composition.html path.");
|
|
88
|
+
const abs = path.resolve(process.cwd(), target);
|
|
89
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
|
90
|
+
const inside = path.join(abs, "composition.html");
|
|
91
|
+
if (!existsSync(inside))
|
|
92
|
+
throw new Error(`No composition.html inside ${abs}. Run \`vidfarm pull <forkId> --dir ${target}\` first.`);
|
|
93
|
+
return inside;
|
|
94
|
+
}
|
|
95
|
+
if (!existsSync(abs))
|
|
96
|
+
throw new Error(`No such composition file or dir: ${abs}.`);
|
|
97
|
+
return abs;
|
|
98
|
+
}
|
|
99
|
+
function num(values, key) {
|
|
100
|
+
const raw = values[key];
|
|
101
|
+
if (typeof raw !== "string" || !raw.trim())
|
|
102
|
+
return undefined;
|
|
103
|
+
const parsed = Number(raw);
|
|
104
|
+
if (!Number.isFinite(parsed))
|
|
105
|
+
throw new Error(`--${key} must be a number.`);
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
// ── keyframes ────────────────────────────────────────────────────────────────
|
|
109
|
+
export async function runKeyframesCommand(argv) {
|
|
110
|
+
if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
|
|
111
|
+
console.log(KEYFRAMES_HELP);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const { values, positionals } = parseArgs({
|
|
115
|
+
args: argv,
|
|
116
|
+
allowPositionals: true,
|
|
117
|
+
options: {
|
|
118
|
+
layer: { type: "string" },
|
|
119
|
+
preset: { type: "string" },
|
|
120
|
+
keyframes: { type: "string" },
|
|
121
|
+
easing: { type: "string" },
|
|
122
|
+
duration: { type: "string" },
|
|
123
|
+
json: { type: "boolean" }
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
const file = resolveCompositionHtml(positionals[0]);
|
|
127
|
+
if (typeof values.layer !== "string" || !values.layer.trim()) {
|
|
128
|
+
throw new Error("keyframes requires --layer <layerKey|slug>.");
|
|
129
|
+
}
|
|
130
|
+
let frames;
|
|
131
|
+
if (typeof values.keyframes === "string" && values.keyframes.trim()) {
|
|
132
|
+
let parsed;
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(values.keyframes);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new Error("--keyframes must be a JSON array of stops, e.g. '[{\"offset\":0,\"opacity\":0},{\"offset\":1,\"opacity\":1}]'.");
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(parsed))
|
|
140
|
+
throw new Error("--keyframes must be a JSON ARRAY of stops.");
|
|
141
|
+
frames = parsed;
|
|
142
|
+
}
|
|
143
|
+
else if (typeof values.preset === "string" && values.preset.trim()) {
|
|
144
|
+
const preset = KEYFRAME_PRESETS[values.preset.trim()];
|
|
145
|
+
if (!preset)
|
|
146
|
+
throw new Error(`Unknown --preset "${values.preset}". Use one of: ${Object.keys(KEYFRAME_PRESETS).join(", ")} (or pass --keyframes '<json>').`);
|
|
147
|
+
frames = preset.map((f) => ({ ...f }));
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
throw new Error(`keyframes needs --preset <name> or --keyframes '<json>'. Presets: ${Object.keys(KEYFRAME_PRESETS).join(", ")}.`);
|
|
151
|
+
}
|
|
152
|
+
const result = setLayerKeyframes(readFileSync(file, "utf8"), values.layer, {
|
|
153
|
+
keyframes: frames,
|
|
154
|
+
easing: typeof values.easing === "string" ? values.easing : undefined,
|
|
155
|
+
durationSec: num(values, "duration")
|
|
156
|
+
});
|
|
157
|
+
writeFileSync(file, result.html, "utf8");
|
|
158
|
+
if (values.json) {
|
|
159
|
+
console.log(JSON.stringify({ file, layerKey: result.layerKey, animation: result.animName, stops: result.stops, duration: result.durationSec, easing: result.easing }, null, 2));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
console.log(`Set a ${result.stops}-stop CSS keyframe animation on ${result.layerKey} (${result.animName} ${result.durationSec}s ${result.easing}).`);
|
|
163
|
+
console.log(`Wrote ${file}`);
|
|
164
|
+
}
|
|
165
|
+
// ── move / nudge ─────────────────────────────────────────────────────────────
|
|
166
|
+
export async function runMoveCommand(argv) {
|
|
167
|
+
if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
|
|
168
|
+
console.log(MOVE_HELP);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const { values, positionals } = parseArgs({
|
|
172
|
+
args: argv,
|
|
173
|
+
allowPositionals: true,
|
|
174
|
+
options: {
|
|
175
|
+
layer: { type: "string", multiple: true },
|
|
176
|
+
"delta-start": { type: "string" },
|
|
177
|
+
"delta-track": { type: "string" },
|
|
178
|
+
json: { type: "boolean" }
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
const file = resolveCompositionHtml(positionals[0]);
|
|
182
|
+
const layers = values.layer ?? [];
|
|
183
|
+
if (!layers.length)
|
|
184
|
+
throw new Error("move requires --layer <layerKey|slug> (repeatable).");
|
|
185
|
+
const result = nudgeLayers(readFileSync(file, "utf8"), layers, {
|
|
186
|
+
deltaStart: num(values, "delta-start"),
|
|
187
|
+
deltaTrack: num(values, "delta-track")
|
|
188
|
+
});
|
|
189
|
+
writeFileSync(file, result.html, "utf8");
|
|
190
|
+
if (values.json) {
|
|
191
|
+
console.log(JSON.stringify({ file, moved: result.moved, delta_start: num(values, "delta-start") ?? 0, delta_track: num(values, "delta-track") ?? 0 }, null, 2));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
console.log(`Moved ${result.moved.length} layer(s) by Δstart=${num(values, "delta-start") ?? 0}s Δtrack=${Math.trunc(num(values, "delta-track") ?? 0)}: ${result.moved.join(", ")}.`);
|
|
195
|
+
console.log(`Wrote ${file}`);
|
|
196
|
+
}
|
|
197
|
+
// ── ripple ───────────────────────────────────────────────────────────────────
|
|
198
|
+
export async function runRippleCommand(argv) {
|
|
199
|
+
if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
|
|
200
|
+
console.log(RIPPLE_HELP);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const { values, positionals } = parseArgs({
|
|
204
|
+
args: argv,
|
|
205
|
+
allowPositionals: true,
|
|
206
|
+
options: { at: { type: "string" }, delta: { type: "string" }, json: { type: "boolean" } }
|
|
207
|
+
});
|
|
208
|
+
const file = resolveCompositionHtml(positionals[0]);
|
|
209
|
+
const at = num(values, "at");
|
|
210
|
+
const delta = num(values, "delta");
|
|
211
|
+
if (at === undefined)
|
|
212
|
+
throw new Error("ripple requires --at <sec>.");
|
|
213
|
+
if (delta === undefined)
|
|
214
|
+
throw new Error("ripple requires --delta <sec>.");
|
|
215
|
+
const result = rippleEdit(readFileSync(file, "utf8"), { at, delta });
|
|
216
|
+
writeFileSync(file, result.html, "utf8");
|
|
217
|
+
if (values.json) {
|
|
218
|
+
console.log(JSON.stringify({ file, at, delta, affected: result.affected }, null, 2));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
console.log(`Rippled ${result.affected} clip(s) at ${at}s by ${delta > 0 ? "+" : ""}${delta}s.`);
|
|
222
|
+
console.log(`Wrote ${file}`);
|
|
223
|
+
}
|
|
224
|
+
// ── trim ─────────────────────────────────────────────────────────────────────
|
|
225
|
+
export async function runTrimCommand(argv) {
|
|
226
|
+
if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
|
|
227
|
+
console.log(TRIM_HELP);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const { values, positionals } = parseArgs({
|
|
231
|
+
args: argv,
|
|
232
|
+
allowPositionals: true,
|
|
233
|
+
options: { layer: { type: "string" }, edge: { type: "string" }, to: { type: "string" }, json: { type: "boolean" } }
|
|
234
|
+
});
|
|
235
|
+
const file = resolveCompositionHtml(positionals[0]);
|
|
236
|
+
if (typeof values.layer !== "string" || !values.layer.trim())
|
|
237
|
+
throw new Error("trim requires --layer <layerKey|slug>.");
|
|
238
|
+
if (values.edge !== "start" && values.edge !== "end")
|
|
239
|
+
throw new Error("trim requires --edge start|end.");
|
|
240
|
+
const to = num(values, "to");
|
|
241
|
+
if (to === undefined)
|
|
242
|
+
throw new Error("trim requires --to <sec>.");
|
|
243
|
+
const result = trimLayer(readFileSync(file, "utf8"), values.layer, { edge: values.edge, to });
|
|
244
|
+
writeFileSync(file, result.html, "utf8");
|
|
245
|
+
if (values.json) {
|
|
246
|
+
console.log(JSON.stringify({ file, layerKey: result.layerKey, edge: values.edge, to, updates: result.updates }, null, 2));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const parts = Object.entries(result.updates).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
250
|
+
console.log(`Trimmed ${result.layerKey} ${values.edge} to ${to}s (${parts}).`);
|
|
251
|
+
console.log(`Wrote ${file}`);
|
|
252
|
+
}
|
|
253
|
+
// ── restack / zindex ─────────────────────────────────────────────────────────
|
|
254
|
+
export async function runRestackCommand(argv) {
|
|
255
|
+
if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
|
|
256
|
+
console.log(RESTACK_HELP);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const { values, positionals } = parseArgs({
|
|
260
|
+
args: argv,
|
|
261
|
+
allowPositionals: true,
|
|
262
|
+
options: { layer: { type: "string" }, order: { type: "string" }, track: { type: "string" }, json: { type: "boolean" } }
|
|
263
|
+
});
|
|
264
|
+
const file = resolveCompositionHtml(positionals[0]);
|
|
265
|
+
if (typeof values.layer !== "string" || !values.layer.trim())
|
|
266
|
+
throw new Error("restack requires --layer <layerKey|slug>.");
|
|
267
|
+
const order = values.order;
|
|
268
|
+
if (order && !["front", "back", "forward", "backward"].includes(order)) {
|
|
269
|
+
throw new Error(`--order must be front | back | forward | backward (got "${order}").`);
|
|
270
|
+
}
|
|
271
|
+
const result = restackLayer(readFileSync(file, "utf8"), values.layer, {
|
|
272
|
+
order: order,
|
|
273
|
+
track: num(values, "track")
|
|
274
|
+
});
|
|
275
|
+
writeFileSync(file, result.html, "utf8");
|
|
276
|
+
if (values.json) {
|
|
277
|
+
console.log(JSON.stringify({ file, layerKey: result.layerKey, track: result.track, order: result.order }, null, 2));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
console.log(`Restacked ${result.layerKey} to track ${result.track} (${result.order}; higher = on top).`);
|
|
281
|
+
console.log(`Wrote ${file}`);
|
|
282
|
+
}
|
|
283
|
+
//# sourceMappingURL=timeline-edit.js.map
|