@mevdragon/vidfarm-devcli 0.5.1 → 0.5.3

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/dist/src/cli.js CHANGED
@@ -7,7 +7,7 @@ import { parseArgs } from "node:util";
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { Readable } from "node:stream";
9
9
  import { pipeline } from "node:stream/promises";
10
- import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./services/composition-edit.js";
10
+ import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./devcli/composition-edit.js";
11
11
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
12
12
  // `serve` command boots the FULL editor locally (single origin, disk-backed
13
13
  // records + storage) so power users edit compositions on disk while a browser
@@ -0,0 +1,287 @@
1
+ // Composition editing for the devcli — parse a composition.html on disk, find
2
+ // its blank timeline gaps and scene layers, and insert / replace a media clip.
3
+ // This is the local-agent counterpart to the browser editor's applyAddLayerAction
4
+ // (demo/src/HyperframesStudioEditor.tsx): it emits clips with the SAME data-*
5
+ // attributes and inline styles so `vidfarm serve` live-morphs them and `publish`
6
+ // pushes them identically to a browser-made edit. Pure DOM (linkedom) — no
7
+ // ffmpeg, no network.
8
+ import { parseHTML } from "linkedom";
9
+ const round3 = (v) => Number(v.toFixed(3));
10
+ const clampPercent = (v) => Math.min(100, Math.max(0, v));
11
+ function serialize(document) {
12
+ return `<!doctype html>\n${document.documentElement.outerHTML}`;
13
+ }
14
+ function reduceAspectRatio(width, height) {
15
+ if (!width || !height || width <= 0 || height <= 0)
16
+ return null;
17
+ const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
18
+ const w = Math.round(width);
19
+ const h = Math.round(height);
20
+ const g = gcd(w, h) || 1;
21
+ return `${w / g}:${h / g}`;
22
+ }
23
+ function numAttr(node, name) {
24
+ const raw = Number.parseFloat(node?.getAttribute?.(name) ?? "");
25
+ return Number.isFinite(raw) ? raw : NaN;
26
+ }
27
+ function stylePercent(node, prop) {
28
+ const raw = node?.style?.[prop];
29
+ if (!raw)
30
+ return undefined;
31
+ const match = raw.match(/(-?\d+(?:\.\d+)?)\s*%/);
32
+ return match ? Number(match[1]) : undefined;
33
+ }
34
+ // A clip fills the canvas if it's a timeline proxy, an explicit full-canvas
35
+ // marker, or its geometry is ~0,0,100,100. Mirrors the editor's isFullCanvas.
36
+ function isFullCanvasNode(node) {
37
+ if (node?.getAttribute?.("data-vf-timeline-proxy") === "video")
38
+ return true;
39
+ if (node?.getAttribute?.("data-vf-full-canvas") === "true")
40
+ return true;
41
+ const x = stylePercent(node, "left");
42
+ const y = stylePercent(node, "top");
43
+ const w = stylePercent(node, "width");
44
+ const h = stylePercent(node, "height");
45
+ return x !== undefined && y !== undefined && w !== undefined && h !== undefined
46
+ && x <= 0.5 && y <= 0.5 && w >= 99.5 && h >= 99.5;
47
+ }
48
+ function readCompositionDoc(html) {
49
+ const { document } = parseHTML(html);
50
+ const root = document.querySelector("[data-composition-id]");
51
+ if (!root)
52
+ throw new Error("Composition root ([data-composition-id]) not found in composition.html.");
53
+ return { document, root };
54
+ }
55
+ function collectClips(root) {
56
+ return Array.from(root.querySelectorAll("[data-start]"));
57
+ }
58
+ function layerSummary(node) {
59
+ const start = numAttr(node, "data-start");
60
+ const duration = numAttr(node, "data-duration");
61
+ const track = numAttr(node, "data-track-index");
62
+ return {
63
+ key: node.getAttribute("data-hf-id") || node.id || "",
64
+ kind: node.getAttribute("data-layer-kind"),
65
+ label: node.getAttribute("data-label"),
66
+ slug: node.getAttribute("data-hf-slug"),
67
+ start: Number.isFinite(start) ? start : 0,
68
+ duration: Number.isFinite(duration) ? duration : 0,
69
+ track: Number.isFinite(track) ? track : 0,
70
+ is_timeline_proxy: node.getAttribute("data-vf-timeline-proxy") === "video",
71
+ is_full_canvas: isFullCanvasNode(node),
72
+ src: node.getAttribute("src") || node.getAttribute("data-src") || null
73
+ };
74
+ }
75
+ // Blank spans where NO canvas-filling layer is on screen — the "gaps" an agent
76
+ // fills. Overlays (text/caption/audio) don't count. Mirrors the editor's
77
+ // computeTimelineGaps.
78
+ export function computeCompositionGaps(html) {
79
+ const { root } = readCompositionDoc(html);
80
+ const totalDuration = numAttr(root, "data-duration");
81
+ return gapsFromClips(collectClips(root), Number.isFinite(totalDuration) ? totalDuration : 0);
82
+ }
83
+ function gapsFromClips(clips, totalDuration) {
84
+ const MIN_GAP = 0.2;
85
+ if (!Number.isFinite(totalDuration) || totalDuration <= 0)
86
+ return [];
87
+ const intervals = [];
88
+ for (const node of clips) {
89
+ const kind = (node.getAttribute("data-layer-kind") ?? "").toLowerCase();
90
+ const isProxy = node.getAttribute("data-vf-timeline-proxy") === "video";
91
+ const start = numAttr(node, "data-start");
92
+ const duration = numAttr(node, "data-duration");
93
+ if ((isProxy || ["video", "image", "html", "shape"].includes(kind)) && Number.isFinite(start) && Number.isFinite(duration) && duration > 0) {
94
+ intervals.push({ start, end: start + duration });
95
+ }
96
+ }
97
+ intervals.sort((a, b) => a.start - b.start);
98
+ const merged = [];
99
+ for (const interval of intervals) {
100
+ const last = merged[merged.length - 1];
101
+ if (last && interval.start <= last.end + 0.001)
102
+ last.end = Math.max(last.end, interval.end);
103
+ else
104
+ merged.push({ start: Math.max(0, interval.start), end: interval.end });
105
+ }
106
+ const gaps = [];
107
+ let cursor = 0;
108
+ for (const interval of merged) {
109
+ if (interval.start - cursor >= MIN_GAP) {
110
+ gaps.push({ start: round3(cursor), end: round3(interval.start), duration: round3(interval.start - cursor) });
111
+ }
112
+ cursor = Math.max(cursor, interval.end);
113
+ }
114
+ if (totalDuration - cursor >= MIN_GAP) {
115
+ gaps.push({ start: round3(cursor), end: round3(totalDuration), duration: round3(totalDuration - cursor) });
116
+ }
117
+ return gaps;
118
+ }
119
+ // Full read-out for `vidfarm pull` / agent grounding: dimensions, scenes, gaps.
120
+ export function inspectComposition(html) {
121
+ const { root } = readCompositionDoc(html);
122
+ const clips = collectClips(root);
123
+ const totalDuration = numAttr(root, "data-duration");
124
+ const width = numAttr(root, "data-width");
125
+ const height = numAttr(root, "data-height");
126
+ const w = Number.isFinite(width) ? width : null;
127
+ const h = Number.isFinite(height) ? height : null;
128
+ return {
129
+ composition_id: root.getAttribute("data-composition-id"),
130
+ duration_seconds: Number.isFinite(totalDuration) ? totalDuration : 0,
131
+ width: w,
132
+ height: h,
133
+ aspect_ratio: reduceAspectRatio(w, h),
134
+ layers: clips.map(layerSummary),
135
+ gaps: gapsFromClips(clips, Number.isFinite(totalDuration) ? totalDuration : 0)
136
+ };
137
+ }
138
+ function resolveLayerNode(root, key) {
139
+ const trimmed = key.trim();
140
+ if (!trimmed)
141
+ return null;
142
+ const escaped = trimmed.replace(/["\\]/g, "\\$&");
143
+ return root.querySelector(`[data-hf-id="${escaped}"]`)
144
+ ?? root.querySelector(`[data-hf-slug="${escaped}"]`)
145
+ ?? root.querySelector(`#${escaped.replace(/[^\w-]/g, "")}`)
146
+ ?? null;
147
+ }
148
+ function nextAutoTrack(clips) {
149
+ return clips.reduce((max, node) => {
150
+ const track = numAttr(node, "data-track-index");
151
+ return Number.isFinite(track) ? Math.max(max, track + 1) : max;
152
+ }, 0);
153
+ }
154
+ // Enforce "one clip per (track, time)": if the requested track collides with an
155
+ // existing clip's time range, walk upward to the next free lane.
156
+ function findNonCollidingTrack(clips, range, excludeId) {
157
+ const end = range.start + range.duration;
158
+ let track = Math.max(0, Math.floor(range.track));
159
+ const collides = (t) => clips.some((node) => {
160
+ if ((node.getAttribute("data-hf-id") || node.id) === excludeId)
161
+ return false;
162
+ if (numAttr(node, "data-track-index") !== t)
163
+ return false;
164
+ const s = numAttr(node, "data-start");
165
+ const d = numAttr(node, "data-duration");
166
+ if (!Number.isFinite(s) || !Number.isFinite(d))
167
+ return false;
168
+ return s < end && (s + d) > range.start;
169
+ });
170
+ while (collides(track))
171
+ track += 1;
172
+ return track;
173
+ }
174
+ function buildMediaClip(document, opts, id, track, geom) {
175
+ const node = document.createElement(opts.kind === "video" ? "video" : "img");
176
+ node.id = id;
177
+ node.setAttribute("data-hf-id", id);
178
+ node.setAttribute("data-layer-mode", "publish");
179
+ node.setAttribute("data-layer-kind", opts.kind);
180
+ node.setAttribute("data-viral-note", `AI generated ${opts.kind} (devcli).`);
181
+ node.setAttribute("data-start", String(round3(geom.start)));
182
+ node.setAttribute("data-duration", String(round3(geom.duration)));
183
+ node.setAttribute("data-end", String(round3(geom.start + geom.duration)));
184
+ node.setAttribute("data-track-index", String(track));
185
+ node.setAttribute("data-label", opts.label || (opts.kind === "video" ? "AI video" : "AI image"));
186
+ node.className = "clip";
187
+ if (opts.slug)
188
+ node.setAttribute("data-hf-slug", opts.slug);
189
+ const styles = [
190
+ "position:absolute",
191
+ `left:${clampPercent(geom.x)}%`,
192
+ `top:${clampPercent(geom.y)}%`,
193
+ `width:${clampPercent(geom.width)}%`,
194
+ `height:${clampPercent(geom.height)}%`,
195
+ `z-index:${track}`,
196
+ "overflow:hidden",
197
+ `object-fit:${opts.objectFit || "cover"}`
198
+ ];
199
+ node.setAttribute("src", opts.src);
200
+ if (opts.kind === "video") {
201
+ node.setAttribute("playsinline", "");
202
+ node.setAttribute("preload", "auto");
203
+ const wantsAudio = opts.muted === false || (opts.volume !== undefined && opts.volume > 0);
204
+ if (!wantsAudio)
205
+ node.setAttribute("muted", "");
206
+ node.setAttribute("data-volume", String(opts.volume ?? 1));
207
+ const ps = String(round3(opts.playbackStart ?? 0));
208
+ node.setAttribute("data-media-start", ps);
209
+ node.setAttribute("data-playback-start", ps);
210
+ styles.push("background:#050604");
211
+ }
212
+ node.setAttribute("style", `${styles.join(";")};`);
213
+ return node;
214
+ }
215
+ // Insert a NEW media clip. Returns { html, layerKey } — the layerKey resolves in
216
+ // the editor / later CLI calls. Auto-picks a collision-free track.
217
+ export function insertMediaLayer(html, opts) {
218
+ if (!opts.src?.trim())
219
+ throw new Error("insertMediaLayer requires a src media URL.");
220
+ const { document, root } = readCompositionDoc(html);
221
+ const clips = collectClips(root);
222
+ const totalDuration = numAttr(root, "data-duration");
223
+ const dur = Number.isFinite(totalDuration) ? totalDuration : 0;
224
+ const start = Math.max(0, dur > 0 ? Math.min(dur - 0.1, opts.start ?? 0) : (opts.start ?? 0));
225
+ const remaining = dur > 0 ? Math.max(0.1, dur - start) : 4;
226
+ const duration = Math.max(0.1, dur > 0 ? Math.min(opts.duration ?? Math.min(4, remaining), remaining) : (opts.duration ?? 4));
227
+ const providedKey = opts.layerKey?.trim();
228
+ const genKey = providedKey && /^[A-Za-z][\w-]{0,63}$/.test(providedKey)
229
+ ? providedKey
230
+ : `vf-gen-${Date.now().toString(36)}`;
231
+ const id = genKey.startsWith("element_") ? genKey : `element_${genKey}`;
232
+ const initialTrack = opts.track !== undefined && Number.isFinite(opts.track) ? Math.max(0, Math.floor(opts.track)) : nextAutoTrack(clips);
233
+ const track = findNonCollidingTrack(clips, { start, duration, track: initialTrack }, id);
234
+ const node = buildMediaClip(document, opts, id, track, {
235
+ start,
236
+ duration,
237
+ x: opts.x ?? 0,
238
+ y: opts.y ?? 0,
239
+ width: opts.width ?? 100,
240
+ height: opts.height ?? 100
241
+ });
242
+ root.append(node);
243
+ return { html: serialize(document), layerKey: id };
244
+ }
245
+ // Replace an existing scene/layer with a generated media clip, taking over its
246
+ // timing + geometry (full-canvas when it was a proxy / full-canvas fill).
247
+ export function replaceLayerWithMedia(html, targetKey, opts) {
248
+ if (!opts.src?.trim())
249
+ throw new Error("replaceLayerWithMedia requires a src media URL.");
250
+ const { document, root } = readCompositionDoc(html);
251
+ const target = resolveLayerNode(root, targetKey);
252
+ if (!target)
253
+ throw new Error(`Layer to replace not found: ${targetKey}`);
254
+ const start = opts.start ?? numAttr(target, "data-start");
255
+ const durationRaw = opts.duration ?? numAttr(target, "data-duration");
256
+ const trackRaw = opts.track ?? numAttr(target, "data-track-index");
257
+ const fullCanvas = isFullCanvasNode(target);
258
+ const geom = fullCanvas
259
+ ? { x: 0, y: 0, width: 100, height: 100 }
260
+ : {
261
+ x: opts.x ?? stylePercent(target, "left") ?? 0,
262
+ y: opts.y ?? stylePercent(target, "top") ?? 0,
263
+ width: opts.width ?? stylePercent(target, "width") ?? 100,
264
+ height: opts.height ?? stylePercent(target, "height") ?? 100
265
+ };
266
+ const providedKey = opts.layerKey?.trim();
267
+ const targetId = target.getAttribute("data-hf-id") || target.id || "";
268
+ const genKey = providedKey && providedKey !== targetKey && /^[A-Za-z][\w-]{0,63}$/.test(providedKey)
269
+ ? providedKey
270
+ : `vf-gen-${Date.now().toString(36)}`;
271
+ const id = genKey.startsWith("element_") ? genKey : `element_${genKey}`;
272
+ // Remove the old node, then insert the replacement in its slot.
273
+ if (target.parentNode)
274
+ target.parentNode.removeChild(target);
275
+ const clips = collectClips(root);
276
+ const initialTrack = Number.isFinite(trackRaw) ? Math.max(0, Math.floor(trackRaw)) : nextAutoTrack(clips);
277
+ const track = findNonCollidingTrack(clips, { start: Number.isFinite(start) ? start : 0, duration: Number.isFinite(durationRaw) ? durationRaw : 4, track: initialTrack }, id);
278
+ const node = buildMediaClip(document, opts, id, track, {
279
+ start: Number.isFinite(start) ? start : 0,
280
+ duration: Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : 4,
281
+ ...geom
282
+ });
283
+ root.append(node);
284
+ void targetId;
285
+ return { html: serialize(document), layerKey: id };
286
+ }
287
+ //# sourceMappingURL=composition-edit.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. Point it at a template id, edit composition.html on disk (Claude Code, Codex, etc.), preview live in the browser at https://vidfarm.cc/editor/.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/src/cli.js",
12
+ "dist/src/devcli/**/*.js",
12
13
  "README.md",
13
14
  "SKILL.director.md",
14
15
  "SKILL.platform.md",