@officexapp/vidfarm-devcli 0.21.11 → 0.21.14

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 (28) hide show
  1. package/.agents/skills/hyperframes-core/SKILL.md +2 -0
  2. package/.agents/skills/hyperframes-creative/SKILL.md +1 -0
  3. package/.agents/skills/hyperframes-creative/references/beat-direction.md +17 -0
  4. package/.agents/skills/vidfarm-director/SKILL.md +4 -0
  5. package/.agents/skills/vidfarm-director/recipes/local-edit-render-approve.md +1 -1
  6. package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +23 -6
  7. package/.agents/skills/vidfarm-director/references/core-workflows.md +1 -1
  8. package/.agents/skills/vidfarm-director/references/editor-workflows.md +15 -2
  9. package/.agents/skills/vidfarm-director/references/primitives.md +1 -1
  10. package/.agents/skills/vidfarm-director/references/rest-api.md +1 -1
  11. package/.agents/skills/vidfarm-media/SKILL.md +16 -6
  12. package/README.md +2 -2
  13. package/SKILL.director.md +45 -11
  14. package/demo/dist/app.js +247 -226
  15. package/dist/src/cli.js +198 -30
  16. package/dist/src/devcli/composition-edit.js +99 -23
  17. package/dist/src/devcli/cost-mode.js +13 -4
  18. package/dist/src/devcli/doctor.js +65 -9
  19. package/dist/src/devcli/local-frontend-server.js +342 -50
  20. package/dist/src/devcli/port-utils.js +43 -0
  21. package/dist/src/devcli/process-scan.js +173 -0
  22. package/package.json +3 -1
  23. package/public/serve-shells/editor.html +75 -13
  24. package/public/serve-shells/library-files.html +75 -13
  25. package/public/serve-shells/library-raws.html +75 -13
  26. package/public/serve-shells/tools-clipper.html +75 -13
  27. package/public/serve-shells/tools-image.html +75 -13
  28. package/public/serve-shells/tools-video.html +75 -13
package/dist/src/cli.js CHANGED
@@ -20,6 +20,8 @@ import { resolveFfmpeg } from "./services/clip-curation/ffmpeg.js";
20
20
  import { parseHyperframesJson, runHyperframesCommand } from "./devcli/hyperframes-cli.js";
21
21
  import { renderCompositionStills } from "./devcli/stills.js";
22
22
  import { runDoctorCommand } from "./devcli/doctor.js";
23
+ import { findFreePort } from "./devcli/port-utils.js";
24
+ import { scanLocalServers } from "./devcli/process-scan.js";
23
25
  import { runSkillsCommand } from "./devcli/skills.js";
24
26
  import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
25
27
  import { resolveLocalDataDir, localBackendAvailable, LocalModeUnavailableError, localApiRequest } from "./devcli/local-backend.js";
@@ -91,7 +93,8 @@ Local editor loop:
91
93
  With a template_id or --fork, pulls that composition
92
94
  (or the template's default/shared decomposition) from
93
95
  the cloud host onto local disk to edit offline.
94
- --port <n> Server port (default: 3000)
96
+ --port <n> Server port (default: 3000; auto-advances to the next
97
+ free port so concurrent local jobs never collide)
95
98
  --dir <path> Local data dir (default: ~/.vidfarm/data or VIDFARM_HOME/data)
96
99
  --key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
97
100
  --fork <id> Pull + open a specific cloud fork
@@ -489,7 +492,11 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
489
492
  doctor Health-check the local toolchain: node, (local)
490
493
  ffmpeg, hyperframes engines, Chrome, API key
491
494
  (whoami), provider keys, agent CLI, poisoned
492
- env, port 3000, installed skills [--json]
495
+ env, local serve/preview processes, installed
496
+ skills [--json] [--kill-orphans]
497
+ --kill-orphans Reap orphaned local serve/preview servers
498
+ (running from a deleted package path) so their
499
+ squatted ports are reclaimed
493
500
  skills <list|add <names…>|update> Install/refresh agent skills into → GET /skill-pack/* (or the
494
501
  <root>/.agents/skills/<name> + a relative copies bundled in this repo)
495
502
  .claude/skills symlink; skills-lock.json is
@@ -504,15 +511,19 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
504
511
 
505
512
  Approve & schedule (publish a finished MP4 as a shareable post):
506
513
  approve Approve media into a preview/share page → POST /api/v1/approved/posts
507
- --video <url|file> Primary MP4 (URL, or a local file auto-uploaded to temp/)
508
- --media <url|file> Extra media (repeatable; local files auto-upload to temp/)
509
- --folder <path> Temp-store folder for uploaded local files (default: temp)
514
+ --video <url|file> Primary MP4 (URL, /files path, or local file durable My Files)
515
+ --media <url|file> Extra media (repeatable; local files durable My Files)
516
+ --folder <path> Destination subfolder for uploaded local files (default: approved)
517
+ --temp Upload local files to the 30-day TEMP store instead of
518
+ durable My Files (throwaway preview; share video dies at day 30)
510
519
  --caption <text> Post caption (required)
511
- Approve takes media by URL; a local file is
512
- presigned + PUT straight to S3 + finalized into a
513
- durable URL first (up to 200 MB bypasses the ~6 MB
514
- Lambda body limit), so a locally rendered MP4 works:
520
+ An approved post is PERMANENT, so a local file uploads
521
+ to DURABLE My Files (never expires) by default
522
+ presigned + PUT straight to S3 + finalized (up to
523
+ 200 MB, bypassing the ~6 MB Lambda body). A locally
524
+ rendered MP4 gets a share link that never breaks:
515
525
  'vidfarm approve --video ./final.mp4 --caption "..."'
526
+ (add --temp only for a disposable 30-day preview.)
516
527
  posts List your approved posts → GET /api/v1/approved/posts
517
528
  post <postId> Read one approved post (prints share URL) → GET /api/v1/approved/posts/:postId
518
529
  schedule <postId> Schedule an approved post to a channel → POST /api/v1/approved/posts/:postId/schedules
@@ -606,7 +617,11 @@ Marketplace (paid, cloud-only — the bazaar never renders locally):
606
617
  Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
607
618
  them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
608
619
  to that box's local disk store, free local render). When you DO need a durable
609
- URL (cloud render/approve), keep it under a throwaway 'temp/' folder.
620
+ URL, mind the store: a 'place'/edit intermediate can live in the throwaway
621
+ 'temp/' folder (30-day TTL), but anything you APPROVE is permanent — 'approve'
622
+ uploads local files to DURABLE My Files by default so the share link never dies
623
+ (the 'temp/' store would delete the video after 30 days; use --temp only for a
624
+ disposable preview).
610
625
 
611
626
  Cost spectrum (default to the cheapest approach that works; see SKILL.director.md):
612
627
  free Reuse a decomposed template, swap captions/images/existing MP4s
@@ -641,7 +656,7 @@ Common options (any command):
641
656
  --json Print only the raw JSON response (agent-friendly)
642
657
 
643
658
  Serve-mode options:
644
- --port <n> Local server port (default: 3000)
659
+ --port <n> Local server port (default: 3000; auto-advances to the next free port)
645
660
  --dir <path> Local data dir for disk records + storage (default: ./.vidfarm-local)
646
661
  --key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
647
662
  --fork <id> Open a specific fork (multiple forks editable at once)
@@ -1322,10 +1337,29 @@ async function runFrontendServeCommand(argv) {
1322
1337
  "no-open": { type: "boolean", default: false }
1323
1338
  }
1324
1339
  });
1325
- const port = Number(parsed.values.port);
1326
- if (!Number.isFinite(port) || port <= 0) {
1340
+ const requestedPort = Number(parsed.values.port);
1341
+ if (!Number.isFinite(requestedPort) || requestedPort <= 0) {
1327
1342
  throw new Error(`Invalid --port: ${parsed.values.port}`);
1328
1343
  }
1344
+ // Multiple concurrent local serves must coexist — auto-advance to the next
1345
+ // free port instead of crashing the 2nd job with EADDRINUSE.
1346
+ const port = await findFreePort(requestedPort);
1347
+ if (port !== requestedPort) {
1348
+ let squatterNote = "";
1349
+ try {
1350
+ const holder = scanLocalServers().servers.find((s) => s.port === requestedPort && !s.isSelf);
1351
+ if (holder?.orphaned) {
1352
+ squatterNote = ` (orphaned ${holder.kind} pid ${holder.pid} squatting it — \`vidfarm doctor --kill-orphans\` reclaims it)`;
1353
+ }
1354
+ else if (holder) {
1355
+ squatterNote = ` (held by a running ${holder.kind}, pid ${holder.pid})`;
1356
+ }
1357
+ }
1358
+ catch {
1359
+ // advisory only
1360
+ }
1361
+ console.warn(`[vidfarm] serve: port ${requestedPort} busy${squatterNote}; using ${port} instead.`);
1362
+ }
1329
1363
  const stored = readStoredAuth();
1330
1364
  const host = trimTrailingSlash(String(parsed.values.host ?? stored?.host ?? DEFAULT_HOST));
1331
1365
  // Only reuse the persisted key when it was minted for this host.
@@ -1397,10 +1431,33 @@ async function runServeCommand(argv) {
1397
1431
  "no-cloud": { type: "boolean", default: false }
1398
1432
  }
1399
1433
  });
1400
- const port = Number(parsed.values.port);
1401
- if (!Number.isFinite(port) || port <= 0) {
1434
+ const requestedPort = Number(parsed.values.port);
1435
+ if (!Number.isFinite(requestedPort) || requestedPort <= 0) {
1402
1436
  throw new Error(`Invalid --port: ${parsed.values.port}`);
1403
1437
  }
1438
+ // Customers run several local video jobs at once, so never hard-fail on a
1439
+ // busy port: claim the next free one upward from the request. This makes the
1440
+ // 2nd/3rd `vidfarm serve` "just work" instead of crashing with EADDRINUSE.
1441
+ const port = await findFreePort(requestedPort);
1442
+ if (port !== requestedPort) {
1443
+ // If an ORPHANED serve/preview (running from a deleted package path) is
1444
+ // squatting the requested port, say so — that dead process will never free
1445
+ // it on its own, and `vidfarm doctor --kill-orphans` reclaims it.
1446
+ let squatterNote = "";
1447
+ try {
1448
+ const holder = scanLocalServers().servers.find((s) => s.port === requestedPort && !s.isSelf);
1449
+ if (holder?.orphaned) {
1450
+ squatterNote = ` (an orphaned ${holder.kind} pid ${holder.pid} is squatting it — reclaim with \`vidfarm doctor --kill-orphans\`)`;
1451
+ }
1452
+ else if (holder) {
1453
+ squatterNote = ` (held by a running ${holder.kind}, pid ${holder.pid})`;
1454
+ }
1455
+ }
1456
+ catch {
1457
+ // advisory only
1458
+ }
1459
+ console.warn(`[vidfarm] serve: port ${requestedPort} busy${squatterNote}; using ${port} instead.`);
1460
+ }
1404
1461
  const dataDir = parsed.values.dir
1405
1462
  ? path.resolve(process.cwd(), parsed.values.dir)
1406
1463
  : resolveLocalDataDir();
@@ -1551,7 +1608,7 @@ function printServeBanner(input) {
1551
1608
  console.log(line);
1552
1609
  console.log(` server ${input.base}`);
1553
1610
  console.log(` data dir ${input.dataDir}`);
1554
- console.log(` render ${DIM}local (in-process HyperFrames render — free, no cloud charge)${RESET}`);
1611
+ console.log(` render ${DIM}local (in-process Vidfarm render — free, no cloud charge)${RESET}`);
1555
1612
  if (input.login) {
1556
1613
  console.log(` login ${GREEN}${input.login.email}${RESET} ${input.login.isPaidPlan ? DIM + "(paid)" + RESET : DIM + "(free)" + RESET}`);
1557
1614
  }
@@ -2824,7 +2881,8 @@ async function runCreateCommand(argv) {
2824
2881
  guardBilled(ctx, {
2825
2882
  label: "create (AI base-VIDEO generation + decompose)",
2826
2883
  estimate: "expensive, ~$1+ (AI video)",
2827
- freeAlternative: "fork a template and paint scenes with raw clips + HTML hyperframes instead of AI video"
2884
+ freeAlternative: "fork a template and paint scenes with raw clips + HTML hyperframes, or drop in free stock via " +
2885
+ 'vidfarm media search "<meaning>" --type video|image (Pixabay/Openverse, $0) instead of AI video'
2828
2886
  });
2829
2887
  const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
2830
2888
  // 1. Generate the base video from the prompt.
@@ -3140,7 +3198,7 @@ async function runRenderCommand(argv) {
3140
3198
  console.log(`${DIM}Media prep: ${prepCuts.map((n) => `${n.action} ×${n.refs} (${n.url.slice(0, 60)}…)`).join("; ")}${RESET}`);
3141
3199
  }
3142
3200
  console.log(`${GREEN}Rendered locally in ${(result.durationMs / 1000).toFixed(1)}s → ${result.outputPath}${RESET}`);
3143
- console.log(`${DIM}$0.00 — local render, no cloud charge.${RESET}`);
3201
+ console.log(`${DIM}$0.00 — local Vidfarm render, no cloud charge.${RESET}`);
3144
3202
  }
3145
3203
  return;
3146
3204
  }
@@ -3494,6 +3552,92 @@ async function uploadLocalToTempStore(ctx, absPath, folder) {
3494
3552
  throw new Error("local media upload returned no durable URL.");
3495
3553
  return url;
3496
3554
  }
3555
+ // Pull the durable view URL out of an /me/attachments finalize (or presign)
3556
+ // response. Finalize returns { attachment: { viewUrl } }; presign returns a
3557
+ // top-level view_url before the bytes land.
3558
+ function readAttachmentUrl(json) {
3559
+ const a = json?.attachment ?? {};
3560
+ return a.viewUrl ?? a.view_url ?? a.publicUrl ?? a.public_url ?? json?.view_url ?? json?.s3_url ?? null;
3561
+ }
3562
+ // Upload a local file to DURABLE My Files (root `files`, via /me/attachments),
3563
+ // optionally namescoped under a folder, and return its permanent URL.
3564
+ //
3565
+ // This is the durable twin of uploadLocalToTempStore: same presign → PUT-to-S3
3566
+ // → finalize shape (so a big MP4 never traverses the ~6 MB Lambda body), but the
3567
+ // object is NOT temp-tagged, so it never hits the 30-day lifecycle deletion.
3568
+ // Use it for anything that must outlive 30 days — chiefly a locally rendered MP4
3569
+ // being approved into a shareable cloud post.
3570
+ async function uploadLocalToMyFiles(ctx, absPath, folder) {
3571
+ if (!existsSync(absPath))
3572
+ throw new Error(`No such local file: ${absPath}`);
3573
+ const buffer = readFileSync(absPath);
3574
+ const fileName = path.basename(absPath);
3575
+ const contentType = guessContentType(fileName);
3576
+ // Step 1: presign. On an S3-backed box this returns a direct PUT URL.
3577
+ const presign = await fetch(new URL("/api/v1/user/me/attachments/presign", ctx.host), {
3578
+ method: "POST",
3579
+ headers: { ...buildAuthHeaders(ctx.auth), "content-type": "application/json" },
3580
+ body: JSON.stringify({ file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength, folder_path: folder })
3581
+ });
3582
+ const presignJson = await presign.json().catch(() => null);
3583
+ if (presign.ok && presignJson?.transport === "presigned" && presignJson?.upload?.url) {
3584
+ // Step 2a: PUT the bytes straight to S3 (bypasses the Lambda body limit).
3585
+ const put = await fetch(presignJson.upload.url, {
3586
+ method: presignJson.upload.method || "PUT",
3587
+ headers: presignJson.upload.headers || {},
3588
+ body: new Uint8Array(buffer)
3589
+ });
3590
+ if (!put.ok)
3591
+ throw new Error(`My Files upload failed with HTTP ${put.status}.`);
3592
+ // Step 3: finalize — record the durable attachment now that the bytes landed.
3593
+ const finalize = await fetch(new URL("/api/v1/user/me/attachments", ctx.host), {
3594
+ method: "POST",
3595
+ headers: { ...buildAuthHeaders(ctx.auth), "content-type": "application/json" },
3596
+ body: JSON.stringify({
3597
+ attachment_id: presignJson.attachment_id,
3598
+ file_name: presignJson.file_name || fileName,
3599
+ content_type: presignJson.content_type || contentType,
3600
+ size_bytes: buffer.byteLength,
3601
+ storage_key: presignJson.storage_key,
3602
+ folder_path: presignJson.folder_path
3603
+ })
3604
+ });
3605
+ const finalizeText = await finalize.text();
3606
+ let finalizeJson = null;
3607
+ try {
3608
+ finalizeJson = finalizeText ? JSON.parse(finalizeText) : null;
3609
+ }
3610
+ catch {
3611
+ finalizeJson = null;
3612
+ }
3613
+ if (!finalize.ok)
3614
+ throw new Error(`My Files upload finalize failed (${finalize.status}): ${finalizeText.slice(0, 200)}`);
3615
+ const url = readAttachmentUrl(finalizeJson) ?? presignJson.view_url ?? null;
3616
+ if (!url)
3617
+ throw new Error("My Files upload returned no durable URL.");
3618
+ return url;
3619
+ }
3620
+ // Step 2b (fallback): local-storage box — POST the bytes as multipart form.
3621
+ const form = new FormData();
3622
+ form.append("file", new Blob([buffer], { type: contentType }), fileName);
3623
+ if (folder)
3624
+ form.append("folder_path", folder);
3625
+ const res = await fetch(new URL("/api/v1/user/me/attachments/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
3626
+ const text = await res.text();
3627
+ let json = null;
3628
+ try {
3629
+ json = text ? JSON.parse(text) : null;
3630
+ }
3631
+ catch {
3632
+ json = null;
3633
+ }
3634
+ if (!res.ok)
3635
+ throw new Error(`My Files upload failed (${res.status}): ${text.slice(0, 200)}`);
3636
+ const url = readAttachmentUrl(json);
3637
+ if (!url)
3638
+ throw new Error("My Files upload returned no durable URL.");
3639
+ return url;
3640
+ }
3497
3641
  // A value like "/raws/demos/hero.mp4" or "/files/logos/logo.png" is a My Files
3498
3642
  // directory path (one of the five unified roots), NOT a URL or a local disk
3499
3643
  // path — resolve it through the directory API. (Prefixes are inlined, not a
@@ -3632,9 +3776,17 @@ async function runGenerateCommand(argv) {
3632
3776
  ? {
3633
3777
  label: "AI video generation",
3634
3778
  estimate: "expensive, ~$1+",
3635
- freeAlternative: "reuse a raw clip (vidfarm raws) or author the beat as an HTML hyperframe"
3636
- }
3637
- : { label: "AI image generation", estimate: "cheap, ~$0.01–$0.05" });
3779
+ freeAlternative: 'free stock video vidfarm media search "<meaning>" --type video (Pixabay, $0; needs a free ' +
3780
+ "pixabay key — it may already be saved: check vidfarm provider-keys), reuse a raw clip (vidfarm " +
3781
+ "raws), or author the beat as an HTML hyperframe"
3782
+ }
3783
+ : {
3784
+ label: "AI image generation",
3785
+ estimate: "cheap, ~$0.01–$0.05",
3786
+ freeAlternative: 'free stock image/vector — vidfarm media search "<meaning>" --type image|vector ($0). ' +
3787
+ "Openverse (CC images) is keyless and always on — check it first; a free Pixabay key adds " +
3788
+ "photos/vectors and may already be saved (check vidfarm provider-keys)"
3789
+ });
3638
3790
  const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
3639
3791
  const payload = { prompt };
3640
3792
  if (parsed.values["aspect-ratio"])
@@ -4516,7 +4668,8 @@ async function runMusicCommand(argv) {
4516
4668
  guardBilled(ctx, {
4517
4669
  label: `AI music generation${parsed.values["own-key"] ? " (your ElevenLabs key)" : ""}`,
4518
4670
  estimate: "billed (ElevenLabs)",
4519
- freeAlternative: "reuse a track from My Files / your library, or a licensed BGM asset"
4671
+ freeAlternative: 'free stock music/SFX vidfarm media search "<meaning>" --type bgm|sfx (Openverse CC/CC0, $0, ' +
4672
+ "no key needed), or reuse a track from My Files / your library"
4520
4673
  });
4521
4674
  let musicLengthMs;
4522
4675
  const lengthMs = parsed.values["length-ms"]?.trim();
@@ -5618,6 +5771,7 @@ async function runApproveCommand(argv) {
5618
5771
  title: { type: "string" },
5619
5772
  pinned: { type: "string" },
5620
5773
  folder: { type: "string" },
5774
+ temp: { type: "boolean" },
5621
5775
  tracer: { type: "string" }
5622
5776
  }
5623
5777
  });
@@ -5625,15 +5779,29 @@ async function runApproveCommand(argv) {
5625
5779
  const caption = parsed.values.caption;
5626
5780
  if (!caption)
5627
5781
  throw new Error("approve requires --caption <text>.");
5628
- // An approved post is a shareable cloud page, so any media must be a durable
5629
- // URL the page can load. Pass an http(s) URL through; a local file is uploaded
5630
- // to the ephemeral temp store (default folder `temp/`) first.
5631
- const uploadFolder = parsed.values.folder ?? "temp";
5782
+ // An approved post is a PERMANENT shareable cloud page, so its media must live
5783
+ // as long as the page. A local file therefore uploads to DURABLE My Files by
5784
+ // default (never expires). `--temp` opts into the 30-day temp store for a
5785
+ // throwaway preview the share video then stops loading after 30 days.
5786
+ const useTemp = Boolean(parsed.values.temp);
5787
+ const uploadFolder = parsed.values.folder ?? (useTemp ? "temp" : "approved");
5632
5788
  const resolveApproveMedia = async (value) => {
5633
- if (/^https?:\/\//i.test(value.trim()))
5634
- return value.trim();
5635
- const abs = path.resolve(process.cwd(), value.trim());
5636
- return uploadLocalToTempStore(ctx, abs, uploadFolder);
5789
+ const v = value.trim();
5790
+ if (/^https?:\/\//i.test(v))
5791
+ return v;
5792
+ // A My Files path (/files/… · /raws/… · /projects/… · /approved/… · /temp/…)
5793
+ // already lives server-side — resolve it to its view URL instead of
5794
+ // re-uploading. (A /temp/… path stays 30-day; the caller chose it.)
5795
+ if (looksLikeMyFilesPath(v)) {
5796
+ const resolved = await resolveMyFilesPathToUrl(ctx, v);
5797
+ if (!resolved)
5798
+ throw new Error(`approve media "${value}" looks like a My Files path but no file was found there. Browse it with \`vidfarm directory ls ${v.replace(/\/[^/]*$/, "") || "/"}\` (add --cloud if it lives on vidfarm.cc), or pass the file's view URL.`);
5799
+ return resolved.url;
5800
+ }
5801
+ const abs = path.resolve(process.cwd(), v);
5802
+ return useTemp
5803
+ ? uploadLocalToTempStore(ctx, abs, uploadFolder)
5804
+ : uploadLocalToMyFiles(ctx, abs, uploadFolder);
5637
5805
  };
5638
5806
  const media = [];
5639
5807
  if (parsed.values.video)
@@ -16,8 +16,35 @@ const TRANSITION_PRESET_SET = new Set(TRANSITION_PRESETS);
16
16
  const TRANSITION_OUT_PRESET_SET = new Set(TRANSITION_OUT_PRESETS);
17
17
  const round3 = (v) => Number(v.toFixed(3));
18
18
  const clampPercent = (v) => Math.min(100, Math.max(0, v));
19
+ // Matches an open tag and captures its attribute blob / self-close slash.
20
+ // KEEP IN SYNC with OPEN_TAG_RE in src/services/composition-sanitize.ts.
21
+ const OPEN_TAG_RE = /<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'`=<>]+))?)*)\s*(\/?)>/g;
22
+ // Quoted attribute values only — the capture keeps the quote character.
23
+ // KEEP IN SYNC with QUOTED_ATTR_RE in src/services/composition-sanitize.ts.
24
+ const QUOTED_ATTR_RE = /(\s+[^\s"'>/=]+\s*=\s*)("[^"]*"|'[^']*')/g;
25
+ // Re-escape raw ">" inside quoted attribute values as "&gt;". Legal HTML, but
26
+ // DOM serializers (linkedom here, browser outerHTML in the web editor) emit
27
+ // the raw form, and the sealed StudioApp's string-offset source editor treats
28
+ // the FIRST ">" as the end of an open tag — which once spliced an imported
29
+ // layer into the middle of a root's data-viral-dna JSON. Idempotent.
30
+ // KEEP IN SYNC with escapeGtInAttributeValues in
31
+ // src/services/composition-sanitize.ts (backend) and
32
+ // demo/src/vidfarm-composition-edit.ts (client twin).
33
+ function escapeGtInAttributeValues(html) {
34
+ if (!html || !html.includes(">"))
35
+ return html;
36
+ return html.replace(OPEN_TAG_RE, (match, tag, rawAttrs, selfClose) => {
37
+ if (!rawAttrs || rawAttrs.indexOf(">") < 0)
38
+ return match;
39
+ const fixed = rawAttrs.replace(QUOTED_ATTR_RE, (_m, lead, quoted) => {
40
+ const q = quoted[0];
41
+ return lead + q + quoted.slice(1, -1).replace(/>/g, "&gt;") + q;
42
+ });
43
+ return `<${tag}${fixed}${selfClose ? "/" : ""}>`;
44
+ });
45
+ }
19
46
  function serialize(document) {
20
- return `<!doctype html>\n${document.documentElement.outerHTML}`;
47
+ return escapeGtInAttributeValues(`<!doctype html>\n${document.documentElement.outerHTML}`);
21
48
  }
22
49
  function reduceAspectRatio(width, height) {
23
50
  if (!width || !height || width <= 0 || height <= 0)
@@ -148,10 +175,45 @@ function resolveLayerNode(root, key) {
148
175
  if (!trimmed)
149
176
  return null;
150
177
  const escaped = trimmed.replace(/["\\]/g, "\\$&");
151
- return root.querySelector(`[data-hf-id="${escaped}"]`)
152
- ?? root.querySelector(`[data-hf-slug="${escaped}"]`)
153
- ?? root.querySelector(`#${escaped.replace(/[^\w-]/g, "")}`)
154
- ?? null;
178
+ const byAttr = root.querySelector(`[data-hf-id="${escaped}"]`)
179
+ ?? root.querySelector(`[data-hf-slug="${escaped}"]`);
180
+ if (byAttr)
181
+ return byAttr;
182
+ // id fallback via an attribute selector — a `#…` selector would throw a
183
+ // querySelector SyntaxError when the sanitized key ends up empty/invalid
184
+ // (e.g. an all-symbol key producing bare "#").
185
+ const idSafe = trimmed.replace(/[^\w-]/g, "");
186
+ return idSafe ? (root.querySelector(`[id="${idSafe}"]`) ?? null) : null;
187
+ }
188
+ // A miss should teach, not stonewall: name the layer keys that DO exist so an
189
+ // agent can self-correct in one round trip (mirrors the web editLayerByKey).
190
+ function layerNotFoundError(root, key) {
191
+ const available = collectClips(root).map((node) => {
192
+ const id = node.getAttribute?.("data-hf-id") || node.id || "";
193
+ const slug = node.getAttribute?.("data-hf-slug");
194
+ return slug && slug !== id ? `${id || slug} (slug: ${slug})` : id;
195
+ }).filter(Boolean);
196
+ const hint = available.length
197
+ ? ` Available layer keys: ${available.slice(0, 24).join(", ")}${available.length > 24 ? ", …" : ""}.`
198
+ : " The composition has no [data-start] layers.";
199
+ return new Error(`Layer not found: ${key}.${hint}`);
200
+ }
201
+ // The web editor's appendLayerHtml grows the root's data-duration whenever a
202
+ // clip's end runs past it — mirror that after any op that can push a clip out
203
+ // (insert/duplicate/nudge/ripple/retime/trim/set-media), so devcli edits never
204
+ // leave clips dangling past the render window. Never shrinks.
205
+ function extendRootDurationToCover(root) {
206
+ let furthest = 0;
207
+ for (const node of collectClips(root)) {
208
+ const s = numAttr(node, "data-start");
209
+ const d = numAttr(node, "data-duration");
210
+ if (Number.isFinite(s) && Number.isFinite(d))
211
+ furthest = Math.max(furthest, s + d);
212
+ }
213
+ const total = numAttr(root, "data-duration");
214
+ if (furthest > (Number.isFinite(total) ? total : 0) + 0.001) {
215
+ root.setAttribute("data-duration", String(round3(furthest)));
216
+ }
155
217
  }
156
218
  function nextAutoTrack(clips) {
157
219
  return clips.reduce((max, node) => {
@@ -283,12 +345,18 @@ export function insertMediaLayer(html, opts) {
283
345
  const clips = collectClips(root);
284
346
  const totalDuration = numAttr(root, "data-duration");
285
347
  const dur = Number.isFinite(totalDuration) ? totalDuration : 0;
286
- const start = Math.max(0, dur > 0 ? Math.min(dur - 0.1, opts.start ?? 0) : (opts.start ?? 0));
287
- const remaining = dur > 0 ? Math.max(0.1, dur - start) : 4;
348
+ // An EXPLICIT start/duration may run past the current timeline end the
349
+ // root's data-duration is extended below (extendRootDurationToCover), the
350
+ // same way the web editor's appendLayerHtml grows the composition. Only the
351
+ // DEFAULTS are fitted to the existing timeline.
352
+ const start = Math.max(0, opts.start ?? 0);
353
+ const remaining = dur > start ? dur - start : 0;
288
354
  // Audio beds (narration/music/SFX) default to spanning the rest of the
289
355
  // timeline from their start; visual clips default to a 4s slot.
290
- const defaultDuration = opts.kind === "audio" ? remaining : Math.min(4, remaining);
291
- const duration = Math.max(0.1, dur > 0 ? Math.min(opts.duration ?? defaultDuration, remaining) : (opts.duration ?? 4));
356
+ const defaultDuration = opts.kind === "audio"
357
+ ? (remaining > 0.1 ? remaining : 4)
358
+ : Math.min(4, remaining > 0.1 ? remaining : 4);
359
+ const duration = Math.max(0.1, opts.duration ?? defaultDuration);
292
360
  const providedKey = opts.layerKey?.trim();
293
361
  const genKey = providedKey && /^[A-Za-z][\w-]{0,63}$/.test(providedKey)
294
362
  ? providedKey
@@ -305,6 +373,7 @@ export function insertMediaLayer(html, opts) {
305
373
  height: opts.height ?? 100
306
374
  });
307
375
  root.append(node);
376
+ extendRootDurationToCover(root);
308
377
  return { html: serialize(document), layerKey: id };
309
378
  }
310
379
  const CAPTION_DEFAULT_FRAME = { x: 10, y: 70, width: 80, height: 14 };
@@ -445,7 +514,7 @@ export function replaceLayerWithMedia(html, targetKey, opts) {
445
514
  const { document, root } = readCompositionDoc(html);
446
515
  const target = resolveLayerNode(root, targetKey);
447
516
  if (!target)
448
- throw new Error(`Layer to replace not found: ${targetKey}`);
517
+ throw layerNotFoundError(root, targetKey);
449
518
  const start = opts.start ?? numAttr(target, "data-start");
450
519
  const durationRaw = opts.duration ?? numAttr(target, "data-duration");
451
520
  const trackRaw = opts.track ?? numAttr(target, "data-track-index");
@@ -476,6 +545,7 @@ export function replaceLayerWithMedia(html, targetKey, opts) {
476
545
  ...geom
477
546
  });
478
547
  root.append(node);
548
+ extendRootDurationToCover(root);
479
549
  void targetId;
480
550
  return { html: serialize(document), layerKey: id };
481
551
  }
@@ -621,7 +691,7 @@ export function setLayerTransitions(html, layerKey, opts) {
621
691
  const { document, root } = readCompositionDoc(html);
622
692
  const node = resolveLayerNode(root, layerKey);
623
693
  if (!node)
624
- throw new Error(`Layer not found: ${layerKey}`);
694
+ throw layerNotFoundError(root, layerKey);
625
695
  if (!isSceneClipNode(node) && String(node.tagName || "").toLowerCase() === "audio") {
626
696
  throw new Error("Transitions cannot be applied to audio layers.");
627
697
  }
@@ -729,7 +799,7 @@ export function setLayerKeyframes(html, layerKey, opts) {
729
799
  const { document, root } = readCompositionDoc(html);
730
800
  const node = resolveLayerNode(root, layerKey);
731
801
  if (!node)
732
- throw new Error(`Layer not found: ${layerKey}`);
802
+ throw layerNotFoundError(root, layerKey);
733
803
  const baseId = nodeTimelineId(node) || "layer";
734
804
  const animName = `hfk-${baseId.replace(/[^A-Za-z0-9_-]/g, "-")}`;
735
805
  const clipDuration = numAttr(node, "data-duration");
@@ -788,7 +858,7 @@ export function nudgeLayers(html, keys, opts) {
788
858
  for (const key of cleanKeys) {
789
859
  const seed = resolveLayerNode(root, key);
790
860
  if (!seed)
791
- throw new Error(`Layer not found: ${key}`);
861
+ throw layerNotFoundError(root, key);
792
862
  const groupId = seed.getAttribute("data-vf-group");
793
863
  if (groupId) {
794
864
  const escaped = groupId.replace(/["\\]/g, "\\$&");
@@ -808,6 +878,7 @@ export function nudgeLayers(html, keys, opts) {
808
878
  track: Math.max(0, (Number.isFinite(track) ? track : 0) + Math.trunc(deltaTrack))
809
879
  });
810
880
  }
881
+ extendRootDurationToCover(root);
811
882
  return { html: serialize(document), moved: Array.from(moveSet.values()).map((n) => nodeTimelineId(n)) };
812
883
  }
813
884
  // ripple_edit twin: insert (delta > 0) or close (delta < 0) time at `at`,
@@ -830,6 +901,7 @@ export function rippleEdit(html, opts) {
830
901
  const s = numAttr(node, "data-start");
831
902
  applyTimingToNode(node, { start: Math.max(0, s + opts.delta) });
832
903
  }
904
+ extendRootDurationToCover(root);
833
905
  return { html: serialize(document), affected: affected.length };
834
906
  }
835
907
  // trim_layer twin: move ONE edge of a clip to a composition time. edge='start'
@@ -844,7 +916,7 @@ export function trimLayer(html, layerKey, opts) {
844
916
  const { document, root } = readCompositionDoc(html);
845
917
  const node = resolveLayerNode(root, layerKey);
846
918
  if (!node)
847
- throw new Error(`Layer not found: ${layerKey}`);
919
+ throw layerNotFoundError(root, layerKey);
848
920
  const start = numAttr(node, "data-start");
849
921
  const duration = numAttr(node, "data-duration");
850
922
  const s = Number.isFinite(start) ? start : 0;
@@ -882,6 +954,7 @@ export function trimLayer(html, layerKey, opts) {
882
954
  report.duration = round3(updates.duration);
883
955
  if (updates.playbackStart !== undefined)
884
956
  report.playback_start = round3(updates.playbackStart);
957
+ extendRootDurationToCover(root);
885
958
  return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, updates: report };
886
959
  }
887
960
  // set_layer_zindex twin: restack a layer. Stacking == data-track-index (higher
@@ -896,7 +969,7 @@ export function restackLayer(html, layerKey, opts) {
896
969
  const { document, root } = readCompositionDoc(html);
897
970
  const node = resolveLayerNode(root, layerKey);
898
971
  if (!node)
899
- throw new Error(`Layer not found: ${layerKey}`);
972
+ throw layerNotFoundError(root, layerKey);
900
973
  const clips = collectClips(root);
901
974
  const tracks = clips.map((n) => numAttr(n, "data-track-index")).filter((t) => Number.isFinite(t));
902
975
  const currentTrack = numAttr(node, "data-track-index");
@@ -938,7 +1011,7 @@ export function setLayerText(html, layerKey, text) {
938
1011
  const { document, root } = readCompositionDoc(html);
939
1012
  const node = resolveLayerNode(root, layerKey);
940
1013
  if (!node)
941
- throw new Error(`Layer not found: ${layerKey}`);
1014
+ throw layerNotFoundError(root, layerKey);
942
1015
  const inline = node.querySelector?.("[data-vf-text-inline]");
943
1016
  if (inline)
944
1017
  inline.textContent = text;
@@ -955,7 +1028,7 @@ export function setLayerStyle(html, layerKey, opts) {
955
1028
  const { document, root } = readCompositionDoc(html);
956
1029
  const node = resolveLayerNode(root, layerKey);
957
1030
  if (!node)
958
- throw new Error(`Layer not found: ${layerKey}`);
1031
+ throw layerNotFoundError(root, layerKey);
959
1032
  const changed = [];
960
1033
  if (opts.color !== undefined) {
961
1034
  upsertStyleDecl(node, "color", opts.color);
@@ -1021,7 +1094,7 @@ export function setLayerVisual(html, layerKey, opts) {
1021
1094
  const { document, root } = readCompositionDoc(html);
1022
1095
  const node = resolveLayerNode(root, layerKey);
1023
1096
  if (!node)
1024
- throw new Error(`Layer not found: ${layerKey}`);
1097
+ throw layerNotFoundError(root, layerKey);
1025
1098
  const changed = [];
1026
1099
  if (opts.x !== undefined) {
1027
1100
  upsertStyleDecl(node, "left", `${clampPercent(opts.x)}%`);
@@ -1067,7 +1140,7 @@ export function setLayerMedia(html, layerKey, opts) {
1067
1140
  const { document, root } = readCompositionDoc(html);
1068
1141
  const node = resolveLayerNode(root, layerKey);
1069
1142
  if (!node)
1070
- throw new Error(`Layer not found: ${layerKey}`);
1143
+ throw layerNotFoundError(root, layerKey);
1071
1144
  const tag = String(node.tagName || "").toLowerCase();
1072
1145
  const kind = node.getAttribute("data-layer-kind") || (tag === "img" ? "image" : tag);
1073
1146
  if (tag !== "video" && tag !== "audio" && tag !== "img") {
@@ -1158,6 +1231,7 @@ export function setLayerMedia(html, layerKey, opts) {
1158
1231
  if (changed.length === 0) {
1159
1232
  throw new Error("set-media needs at least one of --src / --volume / --muted / --loop / --playback-start / --source-out / --duration / --object-fit / --object-position / --ken-burns / --transition / --transition-out.");
1160
1233
  }
1234
+ extendRootDurationToCover(root);
1161
1235
  return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, changed, kind: kind || tag };
1162
1236
  }
1163
1237
  /** Set a layer's stable slug + human note (the AI/viral-DNA handle). Pass an
@@ -1168,7 +1242,7 @@ export function setLayerIdentity(html, layerKey, opts) {
1168
1242
  const { document, root } = readCompositionDoc(html);
1169
1243
  const node = resolveLayerNode(root, layerKey);
1170
1244
  if (!node)
1171
- throw new Error(`Layer not found: ${layerKey}`);
1245
+ throw layerNotFoundError(root, layerKey);
1172
1246
  if (opts.slug !== undefined) {
1173
1247
  const slug = opts.slug.trim();
1174
1248
  if (slug)
@@ -1190,7 +1264,7 @@ export function duplicateLayer(html, layerKey, opts = {}) {
1190
1264
  const { document, root } = readCompositionDoc(html);
1191
1265
  const node = resolveLayerNode(root, layerKey);
1192
1266
  if (!node)
1193
- throw new Error(`Layer not found: ${layerKey}`);
1267
+ throw layerNotFoundError(root, layerKey);
1194
1268
  const clips = collectClips(root);
1195
1269
  const start = numAttr(node, "data-start");
1196
1270
  const duration = numAttr(node, "data-duration");
@@ -1208,6 +1282,7 @@ export function duplicateLayer(html, layerKey, opts = {}) {
1208
1282
  const resolvedTrack = findNonCollidingTrack(clips, { start: newStart, duration: d, track: initialTrack }, newId);
1209
1283
  applyTimingToNode(clone, { start: newStart, duration: d, track: resolvedTrack });
1210
1284
  root.append(clone);
1285
+ extendRootDurationToCover(root);
1211
1286
  return { html: serialize(document), layerKey: newId };
1212
1287
  }
1213
1288
  /** Cut a clip in two at splitTime (composition seconds). The tail inherits the
@@ -1216,7 +1291,7 @@ export function splitLayer(html, layerKey, splitTime) {
1216
1291
  const { document, root } = readCompositionDoc(html);
1217
1292
  const node = resolveLayerNode(root, layerKey);
1218
1293
  if (!node)
1219
- throw new Error(`Layer not found: ${layerKey}`);
1294
+ throw layerNotFoundError(root, layerKey);
1220
1295
  const start = numAttr(node, "data-start");
1221
1296
  const duration = numAttr(node, "data-duration");
1222
1297
  const s = Number.isFinite(start) ? start : 0;
@@ -1247,7 +1322,7 @@ export function setLayerTiming(html, layerKey, opts) {
1247
1322
  const { document, root } = readCompositionDoc(html);
1248
1323
  const node = resolveLayerNode(root, layerKey);
1249
1324
  if (!node)
1250
- throw new Error(`Layer not found: ${layerKey}`);
1325
+ throw layerNotFoundError(root, layerKey);
1251
1326
  const clips = collectClips(root);
1252
1327
  const excludeId = nodeTimelineId(node);
1253
1328
  const nextStart = opts.start !== undefined ? Math.max(0, opts.start) : numAttr(node, "data-start");
@@ -1260,6 +1335,7 @@ export function setLayerTiming(html, layerKey, opts) {
1260
1335
  track: resolvedTrack,
1261
1336
  playbackStart: opts.playbackStart
1262
1337
  });
1338
+ extendRootDurationToCover(root);
1263
1339
  return { html: serialize(document), layerKey: excludeId || layerKey, track: resolvedTrack };
1264
1340
  }
1265
1341
  /** Composition-level (canvas / theme) edit: resize the frame, retarget total