@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.
- package/.agents/skills/hyperframes-core/SKILL.md +2 -0
- package/.agents/skills/hyperframes-creative/SKILL.md +1 -0
- package/.agents/skills/hyperframes-creative/references/beat-direction.md +17 -0
- package/.agents/skills/vidfarm-director/SKILL.md +4 -0
- package/.agents/skills/vidfarm-director/recipes/local-edit-render-approve.md +1 -1
- package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +23 -6
- package/.agents/skills/vidfarm-director/references/core-workflows.md +1 -1
- package/.agents/skills/vidfarm-director/references/editor-workflows.md +15 -2
- package/.agents/skills/vidfarm-director/references/primitives.md +1 -1
- package/.agents/skills/vidfarm-director/references/rest-api.md +1 -1
- package/.agents/skills/vidfarm-media/SKILL.md +16 -6
- package/README.md +2 -2
- package/SKILL.director.md +45 -11
- package/demo/dist/app.js +247 -226
- package/dist/src/cli.js +198 -30
- package/dist/src/devcli/composition-edit.js +99 -23
- package/dist/src/devcli/cost-mode.js +13 -4
- package/dist/src/devcli/doctor.js +65 -9
- package/dist/src/devcli/local-frontend-server.js +342 -50
- package/dist/src/devcli/port-utils.js +43 -0
- package/dist/src/devcli/process-scan.js +173 -0
- package/package.json +3 -1
- package/public/serve-shells/editor.html +75 -13
- package/public/serve-shells/library-files.html +75 -13
- package/public/serve-shells/library-raws.html +75 -13
- package/public/serve-shells/tools-clipper.html +75 -13
- package/public/serve-shells/tools-image.html +75 -13
- 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,
|
|
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
|
|
508
|
-
--media <url|file> Extra media (repeatable; local files
|
|
509
|
-
--folder <path>
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
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
|
|
1326
|
-
if (!Number.isFinite(
|
|
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
|
|
1401
|
-
if (!Number.isFinite(
|
|
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
|
|
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
|
|
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:
|
|
3636
|
-
|
|
3637
|
-
|
|
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:
|
|
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
|
|
5629
|
-
//
|
|
5630
|
-
//
|
|
5631
|
-
|
|
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
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
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 ">". 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, ">") + 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
|
-
|
|
152
|
-
?? root.querySelector(`[data-hf-slug="${escaped}"]`)
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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"
|
|
291
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|