@mevdragon/vidfarm-devcli 0.18.1 → 0.19.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.
Files changed (47) 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 +2 -0
  4. package/SKILL.director.md +174 -16
  5. package/SKILL.platform.md +7 -3
  6. package/demo/dist/app.css +1 -1
  7. package/demo/dist/app.js +79 -75
  8. package/dist/src/account-pages-legacy.js +1 -1
  9. package/dist/src/app.js +1694 -217
  10. package/dist/src/cli.js +1365 -105
  11. package/dist/src/config.js +8 -0
  12. package/dist/src/devcli/clips.js +3 -0
  13. package/dist/src/devcli/composition-edit.js +401 -10
  14. package/dist/src/devcli/local-backend.js +123 -0
  15. package/dist/src/devcli/migrate-local.js +140 -0
  16. package/dist/src/devcli/sync.js +311 -0
  17. package/dist/src/devcli/timeline-edit.js +208 -1
  18. package/dist/src/editor-chat.js +37 -18
  19. package/dist/src/frontend/file-directory.js +271 -20
  20. package/dist/src/frontend/homepage-view.js +3 -3
  21. package/dist/src/frontend/template-editor-chat.js +6 -3
  22. package/dist/src/page-shell.js +1 -1
  23. package/dist/src/primitive-context.js +31 -2
  24. package/dist/src/primitive-registry.js +409 -4
  25. package/dist/src/reskin/chat-page.js +103 -15
  26. package/dist/src/reskin/discover-page.js +247 -10
  27. package/dist/src/reskin/document.js +147 -10
  28. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  29. package/dist/src/reskin/inpaint-page.js +2459 -452
  30. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  31. package/dist/src/reskin/library-page.js +324 -82
  32. package/dist/src/reskin/portfolio-page.js +687 -0
  33. package/dist/src/reskin/theme.js +36 -11
  34. package/dist/src/services/billing.js +4 -0
  35. package/dist/src/services/clip-curation/hunt.js +2 -0
  36. package/dist/src/services/clip-records.js +28 -0
  37. package/dist/src/services/file-directory.js +6 -3
  38. package/dist/src/services/hyperframes.js +535 -86
  39. package/dist/src/services/serverless-jobs.js +3 -1
  40. package/dist/src/services/serverless-records.js +43 -0
  41. package/dist/src/services/storage.js +24 -2
  42. package/dist/src/template-editor-pages.js +1 -1
  43. package/package.json +1 -1
  44. package/public/assets/file-directory-app.js +2 -2
  45. package/public/assets/homepage-client-app.js +1 -1
  46. package/public/assets/page-runtime-client-app.js +2 -2
  47. package/public/assets/placeholders/scene-placeholder.png +0 -0
@@ -32,6 +32,7 @@ const schema = z.object({
32
32
  PROVIDER_LEASE_WAIT_TIMEOUT_SECONDS: z.coerce.number().default(5 * 60),
33
33
  PROVIDER_LEASE_INLINE_WAIT_SECONDS: z.coerce.number().default(5),
34
34
  PROVIDER_LEASE_STEP_WAIT_SECONDS: z.coerce.number().int().min(1).default(60),
35
+ RENDER_POLL_WAIT_SECONDS: z.coerce.number().int().min(1).default(20),
35
36
  PROVIDER_LEASE_MAX_STEP_WAIT_SECONDS: z.coerce.number().int().min(1).default(12 * 60 * 60),
36
37
  MAX_ACTIVE_JOBS_PER_CUSTOMER: z.coerce.number().int().min(1).default(30),
37
38
  STORAGE_DRIVER: z.enum(["local", "s3"]).default("local"),
@@ -101,6 +102,13 @@ const schema = z.object({
101
102
  RAPIDAPI_REMOVE_BACKGROUND_URL: z.string().url().default("https://remove-background18.p.rapidapi.com/public/remove-background/url"),
102
103
  RAPIDAPI_REMOVE_BACKGROUND_HOST: z.string().default("remove-background18.p.rapidapi.com"),
103
104
  RAPIDAPI_REMOVE_BACKGROUND_COST_USD: z.coerce.number().min(0).default(0.005),
105
+ // Local sharp chroma-key background removal (no third-party API) — a small
106
+ // compute/convenience wallet fee, far cheaper than the RapidAPI matting above.
107
+ GREENSCREEN_CHROMA_KEY_COST_USD: z.coerce.number().min(0).default(0.002),
108
+ // Two-step "create media overlay": BYOK AI image gen (provider cost rides the
109
+ // caller's own key) + local chroma key. This fee bills only the platform
110
+ // convenience/compute leg of the combined primitive.
111
+ MEDIA_OVERLAY_COST_USD: z.coerce.number().min(0).default(0.008),
104
112
  SUPERAGENCY_KEY: z.string().optional(),
105
113
  ARTIFACT_SIGNING_SECRET: z.string().optional(),
106
114
  HYPERFRAMES_LAMBDA_STACK_NAME: z.string().default("hyperframes-vidfarm-prod"),
@@ -136,6 +136,7 @@ async function runScan(argv) {
136
136
  cloud: { type: "boolean" },
137
137
  url: { type: "string" },
138
138
  tracer: { type: "string" },
139
+ folder: { type: "string" },
139
140
  "api-url": { type: "string" },
140
141
  "api-key": { type: "string" },
141
142
  "dry-run": { type: "boolean" },
@@ -188,6 +189,7 @@ async function runScan(argv) {
188
189
  apiUrl: values["api-url"],
189
190
  apiKey: values["api-key"],
190
191
  tracer: values.tracer,
192
+ folder: values.folder,
191
193
  prompt: guidancePromptRaw,
192
194
  provider: apiProvider,
193
195
  windows: rawWindows,
@@ -455,6 +457,7 @@ async function runScanCloud(input) {
455
457
  prompt: input.prompt ?? "",
456
458
  ...(input.provider ? { provider: input.provider } : {}),
457
459
  ...(input.tracer ? { tracer: input.tracer } : {}),
460
+ ...(input.folder ? { folder_path: input.folder } : {}),
458
461
  hunt_spec: {
459
462
  ...(input.windows.length ? { windows: input.windows } : {}),
460
463
  ...(input.durationBand ? { duration_band: input.durationBand } : {}),
@@ -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
@@ -293,9 +316,12 @@ function resolveCaptionLook(opts) {
293
316
  color: opts.color ?? preset?.color ?? "#ffffff",
294
317
  background: opts.background ?? preset?.background ?? "#000000",
295
318
  backgroundStyle: (opts.backgroundStyle ?? preset?.textBackgroundStyle ?? "plain"),
296
- fontFamily: opts.fontFamily ?? "Montserrat",
319
+ // A 0 (or empty) font size/family renders the caption invisibly — the shared
320
+ // action/flag schema lets callers pass font_size:0 or font_family:"", so treat
321
+ // only a positive size / non-empty family as an intentional override.
322
+ fontFamily: opts.fontFamily?.trim() ? opts.fontFamily.trim() : "Montserrat",
297
323
  fontWeight: opts.fontWeight ?? preset?.fontWeight ?? 800,
298
- fontSize: opts.fontSize ?? 38,
324
+ fontSize: typeof opts.fontSize === "number" && opts.fontSize > 0 ? opts.fontSize : 38,
299
325
  presetId: preset?.id ?? animation
300
326
  };
301
327
  }
@@ -364,10 +390,12 @@ export function insertCaptionLayers(html, cues, opts = {}) {
364
390
  duration: cue.duration,
365
391
  track,
366
392
  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,
393
+ x: typeof opts.x === "number" && Number.isFinite(opts.x) ? opts.x : CAPTION_DEFAULT_FRAME.x,
394
+ y: typeof opts.y === "number" && Number.isFinite(opts.y) ? opts.y : CAPTION_DEFAULT_FRAME.y,
395
+ // A 0 (or missing) width/height would render the caption invisibly — treat
396
+ // only a positive number as an intentional override, else use the default.
397
+ width: typeof opts.width === "number" && opts.width > 0 ? opts.width : CAPTION_DEFAULT_FRAME.width,
398
+ height: typeof opts.height === "number" && opts.height > 0 ? opts.height : CAPTION_DEFAULT_FRAME.height,
371
399
  text: cue.text,
372
400
  color: look.color,
373
401
  background: look.background,
@@ -512,6 +540,27 @@ function setNodeTransitionOut(node, preset, durationSeconds) {
512
540
  upsertStyleDecl(node, "--vf-tr-out-dur", `${round3(duration)}s`);
513
541
  upsertStyleDecl(node, "--vf-tr-out-delay", `${transitionOutDelaySeconds(Number.isFinite(layerDuration) ? layerDuration : 0, duration)}s`);
514
542
  }
543
+ // In-place Ken Burns (still-image pan/zoom) toggle on an EXISTING node — mirrors
544
+ // buildMediaClip's ken-burns block (data-kenburns + --vf-kb-dur + --vf-kb-amount)
545
+ // and the web applyKenBurnsToNode. "none"/null clears it.
546
+ function setNodeKenBurns(node, preset, intensity) {
547
+ if (!preset || preset === "none") {
548
+ node.removeAttribute("data-kenburns");
549
+ upsertStyleDecl(node, "--vf-kb-dur", null);
550
+ upsertStyleDecl(node, "--vf-kb-amount", null);
551
+ return;
552
+ }
553
+ if (!KEN_BURNS_PRESETS.has(preset)) {
554
+ throw new Error(`Unknown ken-burns preset "${preset}". Use one of: none, ${Array.from(KEN_BURNS_PRESETS).join(", ")}.`);
555
+ }
556
+ const dur = numAttr(node, "data-duration");
557
+ node.setAttribute("data-kenburns", preset);
558
+ if (Number.isFinite(dur) && dur > 0)
559
+ upsertStyleDecl(node, "--vf-kb-dur", `${round3(dur)}s`);
560
+ if (intensity !== undefined && Number.isFinite(intensity)) {
561
+ upsertStyleDecl(node, "--vf-kb-amount", String(Math.max(0.04, Math.min(0.5, intensity))));
562
+ }
563
+ }
515
564
  // Bulk pass over the whole timeline — the devcli twin of the AI's
516
565
  // set_transitions action and the editor's junction chips.
517
566
  export function applyTransitionsAcross(html, opts) {
@@ -868,4 +917,346 @@ export function restackLayer(html, layerKey, opts) {
868
917
  applyTimingToNode(node, { track: resolvedTrack });
869
918
  return { html: serialize(document), layerKey: excludeId || layerKey, track: resolvedTrack, order: opts.order ?? "explicit" };
870
919
  }
920
+ // ---------------------------------------------------------------------------
921
+ // Named layer-edit primitives — the devcli twins of the web editor_action verbs
922
+ // set_layer_text / set_layer_style / set_layer_visual / set_layer_identity /
923
+ // duplicate_layer / split_layer / set_layer_timing, plus set_composition. All
924
+ // follow the same seams as the timeline verbs above: readCompositionDoc →
925
+ // resolveLayerNode → mutate via upsertStyleDecl (inline style, seek-safe) +
926
+ // data-* attrs → serialize. Static look = inline style; timing = data-* attrs;
927
+ // caption/text background = data-text-background-*; identity = data-hf-slug/note.
928
+ /** Rewrite a text/caption layer's visible words, preserving the caption
929
+ * word-span/outline wrapper (data-vf-text-inline) when present. */
930
+ export function setLayerText(html, layerKey, text) {
931
+ const { document, root } = readCompositionDoc(html);
932
+ const node = resolveLayerNode(root, layerKey);
933
+ if (!node)
934
+ throw new Error(`Layer not found: ${layerKey}`);
935
+ const inline = node.querySelector?.("[data-vf-text-inline]");
936
+ if (inline)
937
+ inline.textContent = text;
938
+ else
939
+ node.textContent = text;
940
+ node.setAttribute("data-label", text.slice(0, 40));
941
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey };
942
+ }
943
+ /** Restyle a layer: typography, colours, and static appearance. Mirrors the web
944
+ * set_layer_style (+ the opacity lever from set_layer_visual). Text background
945
+ * is attr-driven (data-text-background-*) like the caption renderer expects;
946
+ * everything else is inline CSS via upsertStyleDecl. */
947
+ export function setLayerStyle(html, layerKey, opts) {
948
+ const { document, root } = readCompositionDoc(html);
949
+ const node = resolveLayerNode(root, layerKey);
950
+ if (!node)
951
+ throw new Error(`Layer not found: ${layerKey}`);
952
+ const changed = [];
953
+ if (opts.color !== undefined) {
954
+ upsertStyleDecl(node, "color", opts.color);
955
+ const inline = node.querySelector?.("[data-vf-text-inline]");
956
+ if (inline)
957
+ upsertStyleDecl(inline, "color", opts.color);
958
+ changed.push("color");
959
+ }
960
+ if (opts.backgroundStyle !== undefined || opts.background !== undefined) {
961
+ const bgStyle = opts.backgroundStyle ?? node.getAttribute("data-text-background-style") ?? "plain";
962
+ const bgColor = opts.background ?? node.getAttribute("data-text-background-color") ?? "#000000";
963
+ node.setAttribute("data-text-background-style", bgStyle);
964
+ node.setAttribute("data-text-background-color", bgColor);
965
+ changed.push("background");
966
+ }
967
+ if (opts.fontFamily !== undefined) {
968
+ node.setAttribute("data-font-family", opts.fontFamily);
969
+ upsertStyleDecl(node, "font-family", `'${opts.fontFamily}', 'TikTok Sans', sans-serif`);
970
+ changed.push("font-family");
971
+ }
972
+ if (opts.fontWeight !== undefined) {
973
+ upsertStyleDecl(node, "font-weight", String(opts.fontWeight));
974
+ changed.push("font-weight");
975
+ }
976
+ if (opts.italic !== undefined) {
977
+ upsertStyleDecl(node, "font-style", opts.italic ? "italic" : "normal");
978
+ changed.push("italic");
979
+ }
980
+ if (opts.underline !== undefined) {
981
+ upsertStyleDecl(node, "text-decoration-line", opts.underline ? "underline" : "none");
982
+ changed.push("underline");
983
+ }
984
+ if (opts.textAlign !== undefined) {
985
+ upsertStyleDecl(node, "text-align", opts.textAlign);
986
+ changed.push("text-align");
987
+ }
988
+ if (opts.borderRadius !== undefined) {
989
+ upsertStyleDecl(node, "border-radius", `${opts.borderRadius}px`);
990
+ changed.push("border-radius");
991
+ }
992
+ if (opts.fontSize !== undefined) {
993
+ upsertStyleDecl(node, "font-size", `${opts.fontSize}px`);
994
+ changed.push("font-size");
995
+ }
996
+ if (opts.lineHeight !== undefined) {
997
+ upsertStyleDecl(node, "line-height", String(opts.lineHeight));
998
+ changed.push("line-height");
999
+ }
1000
+ if (opts.letterSpacing !== undefined) {
1001
+ upsertStyleDecl(node, "letter-spacing", `${opts.letterSpacing}px`);
1002
+ changed.push("letter-spacing");
1003
+ }
1004
+ if (opts.opacity !== undefined) {
1005
+ upsertStyleDecl(node, "opacity", String(Math.max(0, Math.min(1, opts.opacity))));
1006
+ changed.push("opacity");
1007
+ }
1008
+ if (changed.length === 0)
1009
+ throw new Error("set-style needs at least one style field.");
1010
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, changed };
1011
+ }
1012
+ /** Geometry + constant opacity, mirroring the web set_layer_visual. All inline. */
1013
+ export function setLayerVisual(html, layerKey, opts) {
1014
+ const { document, root } = readCompositionDoc(html);
1015
+ const node = resolveLayerNode(root, layerKey);
1016
+ if (!node)
1017
+ throw new Error(`Layer not found: ${layerKey}`);
1018
+ const changed = [];
1019
+ if (opts.x !== undefined) {
1020
+ upsertStyleDecl(node, "left", `${clampPercent(opts.x)}%`);
1021
+ changed.push("x");
1022
+ }
1023
+ if (opts.y !== undefined) {
1024
+ upsertStyleDecl(node, "top", `${clampPercent(opts.y)}%`);
1025
+ changed.push("y");
1026
+ }
1027
+ if (opts.width !== undefined) {
1028
+ upsertStyleDecl(node, "width", `${clampPercent(opts.width)}%`);
1029
+ changed.push("width");
1030
+ }
1031
+ if (opts.height !== undefined) {
1032
+ upsertStyleDecl(node, "height", `${clampPercent(opts.height)}%`);
1033
+ changed.push("height");
1034
+ }
1035
+ if (opts.fontSize !== undefined) {
1036
+ upsertStyleDecl(node, "font-size", `${opts.fontSize}px`);
1037
+ changed.push("font-size");
1038
+ }
1039
+ if (opts.borderRadius !== undefined) {
1040
+ upsertStyleDecl(node, "border-radius", `${opts.borderRadius}px`);
1041
+ changed.push("border-radius");
1042
+ }
1043
+ if (opts.opacity !== undefined) {
1044
+ upsertStyleDecl(node, "opacity", String(Math.max(0, Math.min(1, opts.opacity))));
1045
+ changed.push("opacity");
1046
+ }
1047
+ if (changed.length === 0)
1048
+ throw new Error("set-visual needs at least one of x/y/width/height/font-size/border-radius/opacity.");
1049
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, changed };
1050
+ }
1051
+ /**
1052
+ * In-place edit of a MEDIA layer (video / image / audio): the devcli twin of the
1053
+ * web set_layer_media (applySetLayerMediaAction). Writes byte-identical
1054
+ * attributes — critically, a --src swap KEEPS the same node, key, geometry and
1055
+ * timing (unlike `place --replace`, which deletes the node and re-inserts a new
1056
+ * one with a fresh key). Subrange sugar: --source-out sets the timeline duration
1057
+ * to (source_out − in-point), exactly like the web MediaSegmentModal's in/out.
1058
+ */
1059
+ export function setLayerMedia(html, layerKey, opts) {
1060
+ const { document, root } = readCompositionDoc(html);
1061
+ const node = resolveLayerNode(root, layerKey);
1062
+ if (!node)
1063
+ throw new Error(`Layer not found: ${layerKey}`);
1064
+ const tag = String(node.tagName || "").toLowerCase();
1065
+ const kind = node.getAttribute("data-layer-kind") || (tag === "img" ? "image" : tag);
1066
+ if (tag !== "video" && tag !== "audio" && tag !== "img") {
1067
+ 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.`);
1068
+ }
1069
+ const changed = [];
1070
+ if (opts.src !== undefined) {
1071
+ const src = opts.src.trim();
1072
+ if (!src)
1073
+ throw new Error("set-media --src cannot be empty.");
1074
+ node.setAttribute("src", src);
1075
+ changed.push("src");
1076
+ }
1077
+ if (opts.volume !== undefined) {
1078
+ node.setAttribute("data-volume", String(opts.volume));
1079
+ changed.push("volume");
1080
+ }
1081
+ if (opts.muted !== undefined) {
1082
+ if (opts.muted)
1083
+ node.setAttribute("muted", "");
1084
+ else
1085
+ node.removeAttribute("muted");
1086
+ changed.push("muted");
1087
+ }
1088
+ if (opts.objectFit !== undefined) {
1089
+ upsertStyleDecl(node, "object-fit", opts.objectFit);
1090
+ changed.push("object-fit");
1091
+ }
1092
+ if (opts.objectPosition !== undefined) {
1093
+ upsertStyleDecl(node, "object-position", opts.objectPosition);
1094
+ changed.push("object-position");
1095
+ }
1096
+ if (opts.kenBurns !== undefined) {
1097
+ setNodeKenBurns(node, opts.kenBurns, opts.kenBurnsIntensity);
1098
+ changed.push("ken-burns");
1099
+ }
1100
+ if (opts.transition !== undefined) {
1101
+ setNodeTransition(node, opts.transition, opts.transitionDuration);
1102
+ changed.push("transition");
1103
+ }
1104
+ if (opts.transitionOut !== undefined) {
1105
+ setNodeTransitionOut(node, opts.transitionOut, opts.transitionOutDuration);
1106
+ changed.push("transition-out");
1107
+ }
1108
+ // Subrange: in-point (media-start) and optional out-point → timeline duration.
1109
+ let nextDuration = opts.duration;
1110
+ if (opts.sourceOut !== undefined) {
1111
+ const currentIn = numAttr(node, "data-media-start");
1112
+ const inPoint = opts.playbackStart !== undefined ? opts.playbackStart : (Number.isFinite(currentIn) ? currentIn : 0);
1113
+ const span = opts.sourceOut - inPoint;
1114
+ if (!(span > 0))
1115
+ throw new Error(`--source-out (${opts.sourceOut}s) must be greater than the in-point (${inPoint}s).`);
1116
+ nextDuration = span;
1117
+ }
1118
+ if (opts.playbackStart !== undefined || nextDuration !== undefined) {
1119
+ applyTimingToNode(node, {
1120
+ playbackStart: opts.playbackStart,
1121
+ duration: nextDuration !== undefined ? Math.max(0.1, nextDuration) : undefined
1122
+ });
1123
+ if (opts.playbackStart !== undefined)
1124
+ changed.push("playback-start");
1125
+ if (nextDuration !== undefined)
1126
+ changed.push("duration");
1127
+ }
1128
+ if (changed.length === 0) {
1129
+ 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.");
1130
+ }
1131
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, changed, kind: kind || tag };
1132
+ }
1133
+ /** Set a layer's stable slug + human note (the AI/viral-DNA handle). Pass an
1134
+ * empty string to clear a note. Mirrors the web set_layer_identity. */
1135
+ export function setLayerIdentity(html, layerKey, opts) {
1136
+ if (opts.slug === undefined && opts.note === undefined)
1137
+ throw new Error("set-identity needs --slug and/or --note.");
1138
+ const { document, root } = readCompositionDoc(html);
1139
+ const node = resolveLayerNode(root, layerKey);
1140
+ if (!node)
1141
+ throw new Error(`Layer not found: ${layerKey}`);
1142
+ if (opts.slug !== undefined) {
1143
+ const slug = opts.slug.trim();
1144
+ if (slug)
1145
+ node.setAttribute("data-hf-slug", slug);
1146
+ else
1147
+ node.removeAttribute("data-hf-slug");
1148
+ }
1149
+ if (opts.note !== undefined) {
1150
+ if (opts.note.length)
1151
+ node.setAttribute("data-hf-note", opts.note);
1152
+ else
1153
+ node.removeAttribute("data-hf-note");
1154
+ }
1155
+ return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey };
1156
+ }
1157
+ /** Clone a layer, offsetting its start so the copy doesn't sit exactly on top,
1158
+ * and resolving to a collision-free track. Mirrors the web duplicate_layer. */
1159
+ export function duplicateLayer(html, layerKey, opts = {}) {
1160
+ const { document, root } = readCompositionDoc(html);
1161
+ const node = resolveLayerNode(root, layerKey);
1162
+ if (!node)
1163
+ throw new Error(`Layer not found: ${layerKey}`);
1164
+ const clips = collectClips(root);
1165
+ const start = numAttr(node, "data-start");
1166
+ const duration = numAttr(node, "data-duration");
1167
+ const track = numAttr(node, "data-track-index");
1168
+ const s = Number.isFinite(start) ? start : 0;
1169
+ const d = Number.isFinite(duration) ? duration : 0.1;
1170
+ const clone = node.cloneNode(true);
1171
+ const providedKey = opts.newKey?.trim();
1172
+ const genKey = providedKey && /^[A-Za-z][\w-]{0,63}$/.test(providedKey) ? providedKey : `vf-dup-${Date.now().toString(36)}`;
1173
+ const newId = genKey.startsWith("element_") ? genKey : `element_${genKey}`;
1174
+ clone.id = newId;
1175
+ clone.setAttribute("data-hf-id", newId);
1176
+ const newStart = opts.start !== undefined && Number.isFinite(opts.start) ? Math.max(0, opts.start) : s + d;
1177
+ const initialTrack = opts.track !== undefined && Number.isFinite(opts.track) ? Math.max(0, Math.floor(opts.track)) : (Number.isFinite(track) ? track : 0);
1178
+ const resolvedTrack = findNonCollidingTrack(clips, { start: newStart, duration: d, track: initialTrack }, newId);
1179
+ applyTimingToNode(clone, { start: newStart, duration: d, track: resolvedTrack });
1180
+ root.append(clone);
1181
+ return { html: serialize(document), layerKey: newId };
1182
+ }
1183
+ /** Cut a clip in two at splitTime (composition seconds). The tail inherits the
1184
+ * media in-point offset so video/audio stays continuous. Mirrors split_layer. */
1185
+ export function splitLayer(html, layerKey, splitTime) {
1186
+ const { document, root } = readCompositionDoc(html);
1187
+ const node = resolveLayerNode(root, layerKey);
1188
+ if (!node)
1189
+ throw new Error(`Layer not found: ${layerKey}`);
1190
+ const start = numAttr(node, "data-start");
1191
+ const duration = numAttr(node, "data-duration");
1192
+ const s = Number.isFinite(start) ? start : 0;
1193
+ const d = Number.isFinite(duration) ? duration : 0;
1194
+ if (!(splitTime > s + 0.001 && splitTime < s + d - 0.001)) {
1195
+ throw new Error(`--at ${splitTime}s must fall strictly inside the clip (${round3(s)}–${round3(s + d)}s).`);
1196
+ }
1197
+ const tail = node.cloneNode(true);
1198
+ const tailId = `element_vf-split-${Date.now().toString(36)}`;
1199
+ tail.id = tailId;
1200
+ tail.setAttribute("data-hf-id", tailId);
1201
+ // First half: trim end to the split point.
1202
+ applyTimingToNode(node, { duration: splitTime - s });
1203
+ // Tail: starts at split, remaining duration, media in-point advanced by the cut.
1204
+ const priorMediaStart = numAttr(node, "data-media-start");
1205
+ const tailMediaStart = (Number.isFinite(priorMediaStart) ? priorMediaStart : 0) + (splitTime - s);
1206
+ applyTimingToNode(tail, { start: splitTime, duration: s + d - splitTime, playbackStart: tailMediaStart });
1207
+ root.append(tail);
1208
+ return { html: serialize(document), firstKey: nodeTimelineId(node) || layerKey, secondKey: tailId };
1209
+ }
1210
+ /** Absolute set of a layer's timing (start/duration/track/playback-start),
1211
+ * resolving track collisions. The devcli twin of the web set_layer_timing —
1212
+ * unlike trim (edges) or nudge (relative), this sets values directly. */
1213
+ export function setLayerTiming(html, layerKey, opts) {
1214
+ if (opts.start === undefined && opts.duration === undefined && opts.track === undefined && opts.playbackStart === undefined) {
1215
+ throw new Error("retime needs at least one of --start/--duration/--track/--playback-start.");
1216
+ }
1217
+ const { document, root } = readCompositionDoc(html);
1218
+ const node = resolveLayerNode(root, layerKey);
1219
+ if (!node)
1220
+ throw new Error(`Layer not found: ${layerKey}`);
1221
+ const clips = collectClips(root);
1222
+ const excludeId = nodeTimelineId(node);
1223
+ const nextStart = opts.start !== undefined ? Math.max(0, opts.start) : numAttr(node, "data-start");
1224
+ const nextDur = opts.duration !== undefined ? Math.max(0.1, opts.duration) : numAttr(node, "data-duration");
1225
+ const wantTrack = opts.track !== undefined ? Math.max(0, Math.floor(opts.track)) : numAttr(node, "data-track-index");
1226
+ const resolvedTrack = findNonCollidingTrack(clips, { start: Number.isFinite(nextStart) ? nextStart : 0, duration: Number.isFinite(nextDur) ? nextDur : 0.1, track: Number.isFinite(wantTrack) ? wantTrack : 0 }, excludeId);
1227
+ applyTimingToNode(node, {
1228
+ start: opts.start !== undefined ? Math.max(0, opts.start) : undefined,
1229
+ duration: opts.duration !== undefined ? Math.max(0.1, opts.duration) : undefined,
1230
+ track: resolvedTrack,
1231
+ playbackStart: opts.playbackStart
1232
+ });
1233
+ return { html: serialize(document), layerKey: excludeId || layerKey, track: resolvedTrack };
1234
+ }
1235
+ /** Composition-level (canvas / theme) edit: resize the frame, retarget total
1236
+ * render length, or recolour the canvas background. Written on the
1237
+ * [data-composition-id] root per the hyperframes contract (data-width/height/
1238
+ * duration + a background colour). The devcli twin of the web set_composition. */
1239
+ export function setComposition(html, opts) {
1240
+ const { document, root } = readCompositionDoc(html);
1241
+ const changed = [];
1242
+ if (opts.width !== undefined && Number.isFinite(opts.width) && opts.width > 0) {
1243
+ root.setAttribute("data-width", String(Math.round(opts.width)));
1244
+ changed.push(`width=${Math.round(opts.width)}`);
1245
+ }
1246
+ if (opts.height !== undefined && Number.isFinite(opts.height) && opts.height > 0) {
1247
+ root.setAttribute("data-height", String(Math.round(opts.height)));
1248
+ changed.push(`height=${Math.round(opts.height)}`);
1249
+ }
1250
+ if (opts.duration !== undefined && Number.isFinite(opts.duration) && opts.duration > 0) {
1251
+ root.setAttribute("data-duration", String(round3(opts.duration)));
1252
+ changed.push(`duration=${round3(opts.duration)}`);
1253
+ }
1254
+ if (opts.backgroundColor !== undefined && opts.backgroundColor.trim()) {
1255
+ upsertStyleDecl(root, "background", opts.backgroundColor.trim());
1256
+ changed.push(`background=${opts.backgroundColor.trim()}`);
1257
+ }
1258
+ if (changed.length === 0)
1259
+ throw new Error("set-composition needs at least one of --width/--height/--duration/--background.");
1260
+ return { html: serialize(document), changed };
1261
+ }
871
1262
  //# sourceMappingURL=composition-edit.js.map
@@ -0,0 +1,123 @@
1
+ // In-process LOCAL backend for the devcli — the "local" half of local-first,
2
+ // dual local/cloud operation.
3
+ //
4
+ // The whole Vidfarm backend already runs over disk-backed drivers when
5
+ // RECORDS_DRIVER=local + STORAGE_DRIVER=local (the same drivers `vidfarm serve`
6
+ // uses). Rather than re-implement the directory / files / raws logic a second
7
+ // time, we boot the real Hono `app` in-process and drive it with `app.request()`
8
+ // — Hono's fetch interface runs the full middleware + handler stack WITHOUT
9
+ // binding a socket. So `vidfarm directory ls --local` and `vidfarm serve` share
10
+ // one on-disk backend, and a `clips scan --local` is instantly browsable in a
11
+ // running serve UI.
12
+ //
13
+ // IMPORTANT (env-before-import): `config.ts` binds the driver + data dir from
14
+ // env at module load. We MUST set the local env BEFORE the first `import` of
15
+ // `../app.js` / any service. `cli.ts`'s static import graph deliberately does
16
+ // not reach `config.js`, so setting env here (then dynamic-importing) flips the
17
+ // drivers correctly — exactly the discipline `runServeCommand` follows.
18
+ import path from "node:path";
19
+ import { homedir } from "node:os";
20
+ /** The durable per-user local home. Everything the local backend persists lives
21
+ * under here: records + storage at `<home>/data`, legacy clips at
22
+ * `<home>/clips.db`, provider config at `<home>/config.json`. */
23
+ export function resolveLocalHome(overrideDir) {
24
+ if (overrideDir)
25
+ return path.resolve(overrideDir);
26
+ if (process.env.VIDFARM_HOME)
27
+ return path.resolve(process.env.VIDFARM_HOME);
28
+ return path.join(homedir(), ".vidfarm");
29
+ }
30
+ /** The data dir (records + storage) for the local backend. */
31
+ export function resolveLocalDataDir(overrideDir) {
32
+ return path.join(resolveLocalHome(overrideDir), "data");
33
+ }
34
+ let cached = null;
35
+ /**
36
+ * Boot (once) the in-process local backend and return a handle. Idempotent:
37
+ * repeated calls return the same booted app. Safe to call from any local
38
+ * command; cloud-only commands never call this, so the cloud path is untouched.
39
+ */
40
+ export async function withLocalBackend(opts = {}) {
41
+ if (cached)
42
+ return cached;
43
+ const home = resolveLocalHome(opts.home);
44
+ const dataDir = resolveLocalDataDir(opts.home);
45
+ // Force the fully-local drivers, and blank every knob that would otherwise
46
+ // route this in-process box at the cloud (mirrors runServeCommand). These
47
+ // MUST be set before app.js / config.js is imported below.
48
+ process.env.RECORDS_DRIVER = "local";
49
+ process.env.STORAGE_DRIVER = "local";
50
+ process.env.AWS_S3_BUCKET = "";
51
+ process.env.VIDFARM_JOB_STATE_MACHINE_ARN = "";
52
+ process.env.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN = "";
53
+ process.env.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL = "";
54
+ process.env.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_SECRET = "";
55
+ // The one records-touching service that bypasses the local shim and talks to a
56
+ // real DynamoDB table via middleware — blank it or every /api/* 500s when a
57
+ // dev-shell .env carries a table name. In-process service calls don't hit
58
+ // middleware, but app.request() does, so this must be blank.
59
+ process.env.VIDFARM_RATE_LIMITS_TABLE_NAME = "";
60
+ process.env.VIDFARM_DATA_DIR = dataDir;
61
+ if (!process.env.NODE_ENV)
62
+ process.env.NODE_ENV = "development";
63
+ process.env.HYPERFRAMES_NO_TELEMETRY ||= "1";
64
+ process.env.HYPERFRAMES_SKIP_SKILLS ||= "1";
65
+ // No cloud passthrough by default for pure-local commands; sync/`--cloud` reach
66
+ // the cloud host directly via the devcli's own apiRequest, not this app.
67
+ process.env.VIDFARM_UPSTREAM_HOST ||= "";
68
+ process.env.VIDFARM_UPSTREAM_API_KEY ||= "";
69
+ // A stable local bootstrap key. The same value auto-provisions the local
70
+ // customer (tryBootstrapFromEnv) and authenticates app.request() calls. An
71
+ // empty/whitespace override falls through to the env key or the fixed default
72
+ // (so `--api-key ""` never breaks the local backend).
73
+ const apiKey = (opts.apiKey && opts.apiKey.trim()) ||
74
+ (process.env.VIDFARM_API_KEY && process.env.VIDFARM_API_KEY.trim()) ||
75
+ "vidfarm-local-dev-key";
76
+ process.env.VIDFARM_API_KEY = apiKey;
77
+ // Dynamic imports AFTER env is set, so config.ts binds the local drivers.
78
+ const { ServerlessAuthService } = await import("../services/serverless-auth.js");
79
+ const customer = await new ServerlessAuthService().authenticate(apiKey);
80
+ const appMod = await import("../app.js");
81
+ const app = appMod.default;
82
+ cached = { app, apiKey, ownerId: customer.id, home };
83
+ return cached;
84
+ }
85
+ /**
86
+ * Dispatch a REST call to the in-process local app. Mirrors the cloud
87
+ * `apiRequest` shape so commands can branch on target without reshaping results.
88
+ */
89
+ export async function localApiRequest(input) {
90
+ const backend = await withLocalBackend({ home: input.home, apiKey: input.auth?.apiKey });
91
+ // A synthetic origin — only the path + query matter to Hono routing.
92
+ const url = new URL(input.path, "http://vidfarm.local");
93
+ for (const [key, value] of Object.entries(input.query ?? {})) {
94
+ if (value !== undefined && value !== null && value !== "")
95
+ url.searchParams.set(key, value);
96
+ }
97
+ const headers = {
98
+ accept: "application/json",
99
+ "vidfarm-api-key": input.auth?.apiKey || backend.apiKey
100
+ };
101
+ if (input.auth?.shareToken)
102
+ headers["vidfarm-share-token"] = input.auth.shareToken;
103
+ let body;
104
+ if (input.body !== undefined) {
105
+ headers["content-type"] = "application/json";
106
+ body = JSON.stringify(input.body);
107
+ }
108
+ const res = await backend.app.request(url.pathname + url.search, {
109
+ method: input.method.toUpperCase(),
110
+ headers,
111
+ body
112
+ });
113
+ const text = await res.text();
114
+ let json = null;
115
+ try {
116
+ json = text ? JSON.parse(text) : null;
117
+ }
118
+ catch {
119
+ json = null;
120
+ }
121
+ return { status: res.status, ok: res.ok, json, text };
122
+ }
123
+ //# sourceMappingURL=local-backend.js.map