@mevdragon/vidfarm-devcli 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/vidfarm-media/SKILL.md +41 -4
- package/SKILL.director.md +17 -3
- package/SKILL.platform.md +3 -3
- package/demo/dist/app.js +69 -69
- package/demo/dist/favicon.ico +0 -0
- package/dist/src/app.js +1550 -187
- package/dist/src/cli.js +227 -7
- package/dist/src/devcli/clip-store.js +29 -2
- package/dist/src/devcli/clips.js +52 -9
- package/dist/src/devcli/composition-edit.js +262 -0
- package/dist/src/devcli/timeline-edit.js +283 -0
- package/dist/src/editor-chat.js +29 -6
- package/dist/src/frontend/discover-client.js +130 -0
- package/dist/src/frontend/discover-store.js +23 -0
- package/dist/src/frontend/file-directory.js +744 -0
- package/dist/src/frontend/template-editor-chat.js +22 -19
- package/dist/src/landing-page.js +24 -7
- package/dist/src/page-shell.js +25 -1
- package/dist/src/reskin/agency-page.js +1 -1
- package/dist/src/reskin/calendar-page.js +2 -1
- package/dist/src/reskin/chat-page.js +319 -72
- package/dist/src/reskin/discover-page.js +486 -31
- package/dist/src/reskin/document.js +1171 -384
- package/dist/src/reskin/help-page.js +1 -0
- package/dist/src/reskin/inpaint-page.js +10 -4
- package/dist/src/reskin/library-page.js +918 -220
- package/dist/src/reskin/login-page.js +6 -6
- package/dist/src/reskin/pricing-page.js +2 -0
- package/dist/src/reskin/settings-page.js +55 -10
- package/dist/src/reskin/theme.js +337 -17
- package/dist/src/services/clip-curation/gemini.js +5 -0
- package/dist/src/services/clip-curation/hunt.js +79 -1
- package/dist/src/services/clip-curation/index.js +2 -1
- package/dist/src/services/clip-curation/local-agent.js +4 -3
- package/dist/src/services/clip-curation/media-select.js +85 -0
- package/dist/src/services/clip-curation/query.js +5 -1
- package/dist/src/services/clip-curation/refine.js +50 -20
- package/dist/src/services/clip-curation/scan.js +10 -3
- package/dist/src/services/clip-curation/taxonomy.js +3 -1
- package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
- package/dist/src/services/clip-records.js +14 -1
- package/dist/src/services/clip-search.js +43 -13
- package/dist/src/services/file-directory.js +114 -0
- package/dist/src/services/storage.js +24 -1
- package/dist/src/services/upstream.js +5 -5
- package/dist/src/template-editor-shell.js +16 -2
- package/package.json +1 -1
- package/public/assets/discover-client-app.js +1 -0
- package/public/assets/file-directory-app.js +2 -0
- package/public/assets/homepage-client-app.js +12 -12
- package/public/assets/page-runtime-client-app.js +24 -24
- package/src/assets/favicon.ico +0 -0
- package/src/assets/logo-vidfarm.png +0 -0
package/dist/src/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLa
|
|
|
12
12
|
import { runCaptionsCommand } from "./devcli/captions.js";
|
|
13
13
|
import { runClipsCommand } from "./devcli/clips.js";
|
|
14
14
|
import { runTransitionsCommand } from "./devcli/transitions.js";
|
|
15
|
+
import { runKeyframesCommand, runMoveCommand, runRestackCommand, runRippleCommand, runTrimCommand } from "./devcli/timeline-edit.js";
|
|
15
16
|
import { MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES, loadSpeechAudioLocally, localGenerateSpeech, localTranscribeSpeech, resolveLocalSpeechAuth } from "./devcli/speech.js";
|
|
16
17
|
import { buildSrtFromSegments, inferSpeechProviderForVoice } from "./services/speech.js";
|
|
17
18
|
import { formatCompositionLintIssues, lintCompositionHtml } from "./services/composition-lint.js";
|
|
@@ -236,6 +237,20 @@ Scene transitions (between clips on a pulled/served composition — local file w
|
|
|
236
237
|
transitions list <dir> Show transitions on every scene clip [--json]
|
|
237
238
|
transitions clear <dir> Remove all transitions
|
|
238
239
|
(run 'vidfarm transitions' for presets & all flags)
|
|
240
|
+
|
|
241
|
+
Fine timeline control (trackpad-level verbs on a pulled/served composition — local file write):
|
|
242
|
+
keyframes <dir> --layer <key> --preset <name>|--keyframes '<json>' Script-free CSS
|
|
243
|
+
@keyframes animation on ONE layer (opacity/translate/
|
|
244
|
+
scale/rotate); previews AND renders. --easing --duration
|
|
245
|
+
move <dir> --layer <key> [--layer …] Shift layers by --delta-start (sec) and/or
|
|
246
|
+
--delta-track (lanes); group-aware. (alias: nudge)
|
|
247
|
+
ripple <dir> --at <sec> --delta <sec> Insert (+) or close (-) time at --at and
|
|
248
|
+
shift every downstream clip so the rest stays in sync
|
|
249
|
+
trim <dir> --layer <key> --edge start|end --to <sec> Move one edge to a time
|
|
250
|
+
(left trim couples the media in-point on video/audio)
|
|
251
|
+
restack <dir> --layer <key> --order front|back|forward|backward|--track <n> Change
|
|
252
|
+
stacking (== track index; higher = on top). (alias: zindex)
|
|
253
|
+
(run 'vidfarm keyframes|move|ripple|trim|restack help' for all flags)
|
|
239
254
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
240
255
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
241
256
|
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
@@ -319,10 +334,19 @@ Files (multi-step flows the devcli handles for you):
|
|
|
319
334
|
--folder <path> Only files under this folder (e.g. a product/offer)
|
|
320
335
|
--search <query> Find files by MEANING (keyword + vector over → POST /api/v1/user/me/attachments/search
|
|
321
336
|
name/folder/notes) instead of listing everything
|
|
337
|
+
--content-type <a,b> With --search: filter the /raws branch by shot-KIND tag
|
|
338
|
+
(talking_head,b_roll,product_shot,screen_recording,demo,
|
|
339
|
+
reaction,interview,establishing,lifestyle,text_graphic)
|
|
322
340
|
get-file <id> [dest] Resolve a My Files id to its URL and download it
|
|
323
341
|
--print Print text contents (md/txt/csv/json) instead of saving
|
|
324
342
|
annotate-file <id|name> Set metadata notes on one My Files entry → PATCH /api/v1/user/me/attachments/:id
|
|
325
343
|
--notes <text> The notes to attach (empty string clears); embedded for vector search
|
|
344
|
+
directory ls [path] Browse the unified file tree (/files·/temp·/raws) → GET /api/v1/user/me/directory
|
|
345
|
+
--limit <n> --offset <n> Paginate (folders + files under a path; default "/")
|
|
346
|
+
directory search <query> Ranked search across the whole tree → POST /api/v1/user/me/directory/search
|
|
347
|
+
--path <scope> Scope to a subtree · --mode auto|semantic|substring|path · --limit <n>
|
|
348
|
+
--content-type <a,b> Filter the /raws branch by shot-KIND tag (talking_head, b_roll, …)
|
|
349
|
+
(alias: dir)
|
|
326
350
|
put-file / get-file / files / annotate-file are the My Files (persistent) set;
|
|
327
351
|
upload is the throwaway temp store for dropping media into a composition.
|
|
328
352
|
Annotate assets you'll want back — esp. character refs (character_sprite_card.png
|
|
@@ -528,6 +552,10 @@ async function main() {
|
|
|
528
552
|
case "annotate-file":
|
|
529
553
|
await runAnnotateFileCommand(rest);
|
|
530
554
|
return;
|
|
555
|
+
case "directory":
|
|
556
|
+
case "dir":
|
|
557
|
+
await runDirectoryCommand(rest);
|
|
558
|
+
return;
|
|
531
559
|
case "raws":
|
|
532
560
|
case "clips":
|
|
533
561
|
await runClipsCommand(rest);
|
|
@@ -538,6 +566,24 @@ async function main() {
|
|
|
538
566
|
case "transitions":
|
|
539
567
|
await runTransitionsCommand(rest);
|
|
540
568
|
return;
|
|
569
|
+
// Fine timeline control — local-file twins of the editor's trackpad verbs.
|
|
570
|
+
case "keyframes":
|
|
571
|
+
await runKeyframesCommand(rest);
|
|
572
|
+
return;
|
|
573
|
+
case "move":
|
|
574
|
+
case "nudge":
|
|
575
|
+
await runMoveCommand(rest);
|
|
576
|
+
return;
|
|
577
|
+
case "ripple":
|
|
578
|
+
await runRippleCommand(rest);
|
|
579
|
+
return;
|
|
580
|
+
case "trim":
|
|
581
|
+
await runTrimCommand(rest);
|
|
582
|
+
return;
|
|
583
|
+
case "restack":
|
|
584
|
+
case "zindex":
|
|
585
|
+
await runRestackCommand(rest);
|
|
586
|
+
return;
|
|
541
587
|
// Local media engines + toolchain (bundled HyperFrames local commands —
|
|
542
588
|
// free, no account — plus vidfarm-native lint/stills/doctor/skills).
|
|
543
589
|
case "remove-background":
|
|
@@ -654,7 +700,7 @@ function emitResult(result, json, frontend) {
|
|
|
654
700
|
// Frontend URL builders — these are the pages a director opens in a browser.
|
|
655
701
|
function editorFrontendUrl(host, templateId, forkId) {
|
|
656
702
|
const base = `${host}/editor/${encodeURIComponent(templateId)}`;
|
|
657
|
-
return forkId ? `${base}
|
|
703
|
+
return forkId ? `${base}/fork/${encodeURIComponent(forkId)}` : base;
|
|
658
704
|
}
|
|
659
705
|
function discoverFrontendUrl(host) {
|
|
660
706
|
return `${host}/discover`;
|
|
@@ -779,7 +825,7 @@ async function runServeCommand(argv) {
|
|
|
779
825
|
}
|
|
780
826
|
const base = `http://localhost:${port}`;
|
|
781
827
|
const editorPath = openTemplateId
|
|
782
|
-
? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ?
|
|
828
|
+
? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `/fork/${encodeURIComponent(openForkId)}` : ""}`
|
|
783
829
|
: "/discover";
|
|
784
830
|
const openUrl = `${base}/auto-login?api_key=${encodeURIComponent(apiKey)}&redirect=${encodeURIComponent(editorPath)}`;
|
|
785
831
|
printServeBanner({ base, dataDir, openUrl, editorPath, cloud: cloudEnabled ? { host, hasKey: Boolean(cloudApiKey) } : null });
|
|
@@ -982,9 +1028,9 @@ function printPublishBanner(input) {
|
|
|
982
1028
|
console.log("");
|
|
983
1029
|
}
|
|
984
1030
|
async function resolveForkId(input) {
|
|
985
|
-
// /editor/<template_id> issues a 302 → /editor/<template_id
|
|
1031
|
+
// /editor/<template_id> issues a 302 → /editor/<template_id>/fork/<id>
|
|
986
1032
|
// whenever the caller (or the template's default fork) has one. Follow the
|
|
987
|
-
// redirect once by hand so we can read the fork id off the
|
|
1033
|
+
// redirect once by hand so we can read the fork id off the path.
|
|
988
1034
|
const url = `${input.host}/editor/${encodeURIComponent(input.templateId)}`;
|
|
989
1035
|
const res = await fetch(url, {
|
|
990
1036
|
method: "GET",
|
|
@@ -996,7 +1042,7 @@ async function resolveForkId(input) {
|
|
|
996
1042
|
if (location) {
|
|
997
1043
|
try {
|
|
998
1044
|
const target = new URL(location, input.host);
|
|
999
|
-
const fork = target.
|
|
1045
|
+
const fork = parseForkFromEditorPath(target.pathname);
|
|
1000
1046
|
if (fork)
|
|
1001
1047
|
return fork;
|
|
1002
1048
|
}
|
|
@@ -1007,6 +1053,12 @@ async function resolveForkId(input) {
|
|
|
1007
1053
|
}
|
|
1008
1054
|
return null;
|
|
1009
1055
|
}
|
|
1056
|
+
// Parse the fork id out of a canonical editor path
|
|
1057
|
+
// /editor/<template_id>/fork/<fork_id> → "<fork_id>" (else null).
|
|
1058
|
+
function parseForkFromEditorPath(pathname) {
|
|
1059
|
+
const match = pathname.match(/^\/editor\/[^/]+\/fork\/([^/]+)\/?$/);
|
|
1060
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
1061
|
+
}
|
|
1010
1062
|
async function fetchCompositionFiles(input) {
|
|
1011
1063
|
// video-context.json carries the source video's audio transcript and
|
|
1012
1064
|
// per-scene visual descriptions so desktop agents (Claude Code, Codex)
|
|
@@ -2002,6 +2054,13 @@ async function resolveReferenceUrls(ctx, refs) {
|
|
|
2002
2054
|
function inferMediaKindFromUrl(url) {
|
|
2003
2055
|
return /\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(url) ? "video" : "image";
|
|
2004
2056
|
}
|
|
2057
|
+
// Comma-separated shot-KIND filter (talking_head, b_roll, product_shot, …) →
|
|
2058
|
+
// the content_type array the /raws branch of the search endpoints honors.
|
|
2059
|
+
function parseContentTypeFlag(value) {
|
|
2060
|
+
if (typeof value !== "string" || !value.trim())
|
|
2061
|
+
return [];
|
|
2062
|
+
return value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
2063
|
+
}
|
|
2005
2064
|
// Resolve a composition.html on disk from a file path or a directory holding one.
|
|
2006
2065
|
function resolveCompositionHtmlPath(target) {
|
|
2007
2066
|
const abs = path.resolve(process.cwd(), target);
|
|
@@ -3214,18 +3273,20 @@ async function runDownloadCommand(argv) {
|
|
|
3214
3273
|
// This is the same filesystem the /editor AI agent browses via the browse_files
|
|
3215
3274
|
// tool, so an agent CLI (Claude Code / Codex) can find assets the same way.
|
|
3216
3275
|
async function runFilesCommand(argv) {
|
|
3217
|
-
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" }, search: { type: "string" } } });
|
|
3276
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" }, search: { type: "string" }, "content-type": { type: "string" } } });
|
|
3218
3277
|
const ctx = commonContext(parsed.values);
|
|
3219
3278
|
const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
|
|
3279
|
+
const contentTypes = parseContentTypeFlag(parsed.values["content-type"]);
|
|
3220
3280
|
if (parsed.values.search) {
|
|
3221
3281
|
// Meaning-first lookup: keyword + vector search over every file's
|
|
3222
3282
|
// name/folder/notes (BYOK embeddings; keyword-only without a saved key).
|
|
3283
|
+
// content_type filters the /raws branch by shot-KIND tag (server-honored).
|
|
3223
3284
|
const result = await apiRequest({
|
|
3224
3285
|
method: "POST",
|
|
3225
3286
|
host: ctx.host,
|
|
3226
3287
|
path: "/api/v1/user/me/attachments/search",
|
|
3227
3288
|
auth: ctx.auth,
|
|
3228
|
-
body: { query: String(parsed.values.search), ...(folderFilter ? { folder_path: folderFilter } : {}) }
|
|
3289
|
+
body: { query: String(parsed.values.search), ...(folderFilter ? { folder_path: folderFilter } : {}), ...(contentTypes.length ? { content_type: contentTypes } : {}) }
|
|
3229
3290
|
});
|
|
3230
3291
|
assertApiOk(result, "files --search");
|
|
3231
3292
|
emitResult(result, ctx.json);
|
|
@@ -3241,6 +3302,165 @@ async function runFilesCommand(argv) {
|
|
|
3241
3302
|
}
|
|
3242
3303
|
emitResult(result, ctx.json);
|
|
3243
3304
|
}
|
|
3305
|
+
// ── directory (unified virtual file tree) ────────────────────────────────────
|
|
3306
|
+
// Browse + search the three file roots (/files, /temp, /raws) as one tree via
|
|
3307
|
+
// the cloud REST directory endpoints. Same cloud auth (--api-key/VIDFARM_API_KEY,
|
|
3308
|
+
// --host, --json) as the rest of the wrapper commands.
|
|
3309
|
+
const DIRECTORY_HELP = `vidfarm directory — browse the unified file tree (/files · /temp · /raws)
|
|
3310
|
+
|
|
3311
|
+
directory ls [path] List folders + files under a path (default "/") → GET /api/v1/user/me/directory
|
|
3312
|
+
--limit <n> --offset <n> Paginate (server returns next_offset when more)
|
|
3313
|
+
--json Raw JSON response
|
|
3314
|
+
|
|
3315
|
+
directory search <query> Ranked search across the tree → POST /api/v1/user/me/directory/search
|
|
3316
|
+
--path <scope> Scope the search to a subtree (e.g. /raws/demos)
|
|
3317
|
+
--mode auto|semantic|substring|path Signal(s) to use (default: auto)
|
|
3318
|
+
--content-type <a,b> Filter the /raws branch by shot-KIND tag (comma-separated:
|
|
3319
|
+
talking_head, b_roll, product_shot, screen_recording, demo,
|
|
3320
|
+
reaction, interview, establishing, lifestyle, text_graphic)
|
|
3321
|
+
--limit <n> --json
|
|
3322
|
+
|
|
3323
|
+
Aliases: directory | dir · Auth: --api-key <key> or VIDFARM_API_KEY, --host <url>
|
|
3324
|
+
Move a raw between folders with: vidfarm api PATCH /raws/<clipId> --data '{"folder_path":"…"}'`;
|
|
3325
|
+
async function runDirectoryCommand(argv) {
|
|
3326
|
+
const sub = argv[0];
|
|
3327
|
+
const rest = argv.slice(1);
|
|
3328
|
+
switch (sub) {
|
|
3329
|
+
case "ls":
|
|
3330
|
+
case "list":
|
|
3331
|
+
return runDirectoryLs(rest);
|
|
3332
|
+
case "search":
|
|
3333
|
+
case "find":
|
|
3334
|
+
return runDirectorySearch(rest);
|
|
3335
|
+
case undefined:
|
|
3336
|
+
case "help":
|
|
3337
|
+
case "--help":
|
|
3338
|
+
case "-h":
|
|
3339
|
+
console.log(DIRECTORY_HELP);
|
|
3340
|
+
return;
|
|
3341
|
+
default:
|
|
3342
|
+
console.error(`Unknown directory subcommand: ${sub}\n`);
|
|
3343
|
+
console.log(DIRECTORY_HELP);
|
|
3344
|
+
process.exitCode = 1;
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
async function runDirectoryLs(argv) {
|
|
3348
|
+
const parsed = parseArgs({
|
|
3349
|
+
args: argv,
|
|
3350
|
+
allowPositionals: true,
|
|
3351
|
+
options: { ...commonOptions(), limit: { type: "string" }, offset: { type: "string" } }
|
|
3352
|
+
});
|
|
3353
|
+
const ctx = commonContext(parsed.values);
|
|
3354
|
+
const dirPath = parsed.positionals[0] ?? "/";
|
|
3355
|
+
const result = await apiRequest({
|
|
3356
|
+
method: "GET",
|
|
3357
|
+
host: ctx.host,
|
|
3358
|
+
path: "/api/v1/user/me/directory",
|
|
3359
|
+
auth: ctx.auth,
|
|
3360
|
+
query: {
|
|
3361
|
+
path: dirPath,
|
|
3362
|
+
limit: parsed.values.limit,
|
|
3363
|
+
offset: parsed.values.offset
|
|
3364
|
+
}
|
|
3365
|
+
});
|
|
3366
|
+
assertApiOk(result, "directory ls");
|
|
3367
|
+
if (ctx.json) {
|
|
3368
|
+
printJson(result.json ?? result.text);
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
printDirectoryListing(result.json, dirPath);
|
|
3372
|
+
}
|
|
3373
|
+
async function runDirectorySearch(argv) {
|
|
3374
|
+
const parsed = parseArgs({
|
|
3375
|
+
args: argv,
|
|
3376
|
+
allowPositionals: true,
|
|
3377
|
+
options: { ...commonOptions(), path: { type: "string" }, mode: { type: "string" }, "content-type": { type: "string" }, limit: { type: "string" } }
|
|
3378
|
+
});
|
|
3379
|
+
const ctx = commonContext(parsed.values);
|
|
3380
|
+
const query = parsed.positionals.join(" ").trim();
|
|
3381
|
+
if (!query) {
|
|
3382
|
+
throw new Error('directory search requires a query: vidfarm directory search "<query>" [--path /raws] [--mode auto|semantic|substring|path] [--content-type talking_head,b_roll] [--limit N]');
|
|
3383
|
+
}
|
|
3384
|
+
const mode = parsed.values.mode;
|
|
3385
|
+
if (mode && !["auto", "semantic", "substring", "path"].includes(mode)) {
|
|
3386
|
+
throw new Error(`--mode must be auto | semantic | substring | path (got "${mode}")`);
|
|
3387
|
+
}
|
|
3388
|
+
const contentTypes = parseContentTypeFlag(parsed.values["content-type"]);
|
|
3389
|
+
const limit = parsed.values.limit != null ? Number(parsed.values.limit) : undefined;
|
|
3390
|
+
const result = await apiRequest({
|
|
3391
|
+
method: "POST",
|
|
3392
|
+
host: ctx.host,
|
|
3393
|
+
path: "/api/v1/user/me/directory/search",
|
|
3394
|
+
auth: ctx.auth,
|
|
3395
|
+
body: {
|
|
3396
|
+
query,
|
|
3397
|
+
...(parsed.values.path ? { path: String(parsed.values.path) } : {}),
|
|
3398
|
+
...(mode ? { mode } : {}),
|
|
3399
|
+
...(contentTypes.length ? { content_type: contentTypes } : {}),
|
|
3400
|
+
...(limit != null && Number.isFinite(limit) ? { limit } : {})
|
|
3401
|
+
}
|
|
3402
|
+
});
|
|
3403
|
+
assertApiOk(result, "directory search");
|
|
3404
|
+
if (ctx.json) {
|
|
3405
|
+
printJson(result.json ?? result.text);
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
3408
|
+
printDirectorySearch(result.json, query);
|
|
3409
|
+
}
|
|
3410
|
+
// Compact "12.3s · 4.2 MB · video/mp4" line for a directory file item.
|
|
3411
|
+
function directoryFileMeta(item) {
|
|
3412
|
+
const parts = [];
|
|
3413
|
+
if (typeof item?.durationSec === "number" && item.durationSec > 0)
|
|
3414
|
+
parts.push(`${item.durationSec.toFixed(1)}s`);
|
|
3415
|
+
if (typeof item?.sizeBytes === "number" && item.sizeBytes > 0)
|
|
3416
|
+
parts.push(formatBytes(item.sizeBytes));
|
|
3417
|
+
if (typeof item?.contentType === "string" && item.contentType)
|
|
3418
|
+
parts.push(item.contentType);
|
|
3419
|
+
return parts.join(" · ");
|
|
3420
|
+
}
|
|
3421
|
+
function printDirectoryListing(data, requestedPath) {
|
|
3422
|
+
const listedPath = data && typeof data.path === "string" ? data.path : requestedPath;
|
|
3423
|
+
const root = data?.root ? ` ${DIM}[${data.root}]${RESET}` : "";
|
|
3424
|
+
console.log(`${BOLD}${listedPath}${RESET}${root}`);
|
|
3425
|
+
const folders = Array.isArray(data?.folders) ? data.folders : [];
|
|
3426
|
+
const files = Array.isArray(data?.files) ? data.files : [];
|
|
3427
|
+
if (folders.length === 0 && files.length === 0) {
|
|
3428
|
+
console.log(` ${DIM}(empty)${RESET}`);
|
|
3429
|
+
}
|
|
3430
|
+
for (const f of folders) {
|
|
3431
|
+
console.log(` ${DIM}dir ${RESET} ${f?.name ?? ""}/ ${DIM}${f?.path ?? ""}${RESET}`);
|
|
3432
|
+
}
|
|
3433
|
+
for (const f of files) {
|
|
3434
|
+
const meta = directoryFileMeta(f);
|
|
3435
|
+
console.log(` file ${f?.name ?? ""}${meta ? ` ${DIM}${meta}${RESET}` : ""} ${DIM}${f?.path ?? ""}${RESET}`);
|
|
3436
|
+
if (f?.viewUrl)
|
|
3437
|
+
console.log(` ${FRONTEND}${f.viewUrl}${RESET}`);
|
|
3438
|
+
}
|
|
3439
|
+
const total = folders.length + files.length;
|
|
3440
|
+
const more = data?.next_offset != null ? ` (more: --offset ${data.next_offset})` : "";
|
|
3441
|
+
console.log(`\n ${total} item${total === 1 ? "" : "s"} (${folders.length} folder${folders.length === 1 ? "" : "s"}, ${files.length} file${files.length === 1 ? "" : "s"})${more}`);
|
|
3442
|
+
}
|
|
3443
|
+
function printDirectorySearch(data, query) {
|
|
3444
|
+
const results = Array.isArray(data?.results) ? data.results : [];
|
|
3445
|
+
const scope = data?.scope ? ` in ${data.scope}` : "";
|
|
3446
|
+
const modeLabel = data?.mode ? ` · mode=${data.mode}` : "";
|
|
3447
|
+
const semantic = data?.semantic === true ? " · semantic" : data?.semantic === false ? " · keyword-only" : "";
|
|
3448
|
+
console.log(`${BOLD}"${query}"${RESET}${scope}${modeLabel}${semantic} → ${results.length} result${results.length === 1 ? "" : "s"}\n`);
|
|
3449
|
+
if (results.length === 0) {
|
|
3450
|
+
console.log(` ${DIM}(no matches)${RESET}`);
|
|
3451
|
+
return;
|
|
3452
|
+
}
|
|
3453
|
+
results.forEach((r, i) => {
|
|
3454
|
+
const score = typeof r?.score === "number" ? ` ${DIM}score=${r.score.toFixed(3)}${RESET}` : "";
|
|
3455
|
+
const kind = r?.kind === "folder" ? "dir " : "file";
|
|
3456
|
+
console.log(` ${String(i + 1).padStart(2)}. ${kind} ${r?.path ?? r?.name ?? ""}${score}`);
|
|
3457
|
+
const meta = directoryFileMeta(r);
|
|
3458
|
+
if (meta)
|
|
3459
|
+
console.log(` ${DIM}${meta}${RESET}`);
|
|
3460
|
+
if (r?.viewUrl)
|
|
3461
|
+
console.log(` ${FRONTEND}${r.viewUrl}${RESET}`);
|
|
3462
|
+
});
|
|
3463
|
+
}
|
|
3244
3464
|
// Attach metadata notes to one My Files entry (by id, or file name as a
|
|
3245
3465
|
// fallback). Notes describe what the file IS and when to use it; the server
|
|
3246
3466
|
// vector-embeds them so `files --search` finds the asset by meaning later —
|
|
@@ -130,7 +130,12 @@ export class ClipStore {
|
|
|
130
130
|
this.attachEmbeddings([clip]);
|
|
131
131
|
return clip;
|
|
132
132
|
}
|
|
133
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* All clips (metadata only), newest first, optionally scoped to a source
|
|
135
|
+
* video. `contentTypes` filters by the shot-KIND tag (tags.content_type,
|
|
136
|
+
* exact match, case-insensitive); when present the limit/offset are applied
|
|
137
|
+
* AFTER the JS filter (the tag lives in tags_json, not a column).
|
|
138
|
+
*/
|
|
134
139
|
listClips(opts = {}) {
|
|
135
140
|
let sql = "SELECT * FROM clips";
|
|
136
141
|
const args = [];
|
|
@@ -139,9 +144,30 @@ export class ClipStore {
|
|
|
139
144
|
args.push(opts.sourceVideoId);
|
|
140
145
|
}
|
|
141
146
|
sql += " ORDER BY created_at DESC";
|
|
142
|
-
|
|
147
|
+
const wanted = (opts.contentTypes ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
148
|
+
if (wanted.length) {
|
|
149
|
+
// Content-type filter can't ride SQL (tag is in JSON), so filter every
|
|
150
|
+
// matching row in JS, then window with offset/limit.
|
|
151
|
+
let clips = this.db.prepare(sql).all(...args)
|
|
152
|
+
.map(rowToClip)
|
|
153
|
+
.filter((c) => (c.tags.content_type ?? []).some((t) => wanted.includes(String(t).toLowerCase())));
|
|
154
|
+
if (opts.offset)
|
|
155
|
+
clips = clips.slice(Math.max(0, Math.floor(opts.offset)));
|
|
156
|
+
if (opts.limit)
|
|
157
|
+
clips = clips.slice(0, opts.limit);
|
|
158
|
+
return clips;
|
|
159
|
+
}
|
|
160
|
+
if (opts.limit != null) {
|
|
143
161
|
sql += " LIMIT ?";
|
|
144
162
|
args.push(opts.limit);
|
|
163
|
+
if (opts.offset) {
|
|
164
|
+
sql += " OFFSET ?";
|
|
165
|
+
args.push(Math.max(0, Math.floor(opts.offset)));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else if (opts.offset) {
|
|
169
|
+
sql += " LIMIT -1 OFFSET ?";
|
|
170
|
+
args.push(Math.max(0, Math.floor(opts.offset)));
|
|
145
171
|
}
|
|
146
172
|
return this.db.prepare(sql).all(...args).map(rowToClip);
|
|
147
173
|
}
|
|
@@ -313,6 +339,7 @@ function rowToClip(row) {
|
|
|
313
339
|
}
|
|
314
340
|
function emptyTags() {
|
|
315
341
|
return {
|
|
342
|
+
content_type: [],
|
|
316
343
|
subject: [],
|
|
317
344
|
action: [],
|
|
318
345
|
emotion: [],
|
package/dist/src/devcli/clips.js
CHANGED
|
@@ -12,7 +12,7 @@ import { homedir } from "node:os";
|
|
|
12
12
|
import { parseArgs } from "node:util";
|
|
13
13
|
import { createInterface } from "node:readline/promises";
|
|
14
14
|
import { createIdV7 } from "../lib/ids.js";
|
|
15
|
-
import { buildEffectiveGuidance, ClipModelClient, detectLocalAgent, detectScenes, estimateScanCostFromScenes, fitScenesToDurationBand, formatCostEstimate, hasFfmpeg, LocalAgentClipClient, normalizeAspect, normalizeCropFocus, normalizeWindows, parseClipHuntPrompt, parseTimeRanges, probeVideo, resolveDurationBand, scanVideo, searchClips } from "../services/clip-curation/index.js";
|
|
15
|
+
import { buildEffectiveGuidance, ClipModelClient, DEFAULT_CLIP_ASPECT, DEFAULT_CLIP_DURATION_BAND, detectLocalAgent, detectScenes, effectiveHuntDurationSec, estimateScanCostFromScenes, fitScenesToDurationBand, formatCostEstimate, hasFfmpeg, LocalAgentClipClient, normalizeAspect, normalizeCropFocus, normalizeWindows, parseClipHuntPrompt, parseTimeRanges, probeVideo, resolveDurationBand, resolveTargetClipCount, scanVideo, searchClips } from "../services/clip-curation/index.js";
|
|
16
16
|
import { ClipStore } from "./clip-store.js";
|
|
17
17
|
export const CLIPS_HELP = `vidfarm raws — build & search a local raws library (local ffmpeg + local agent by default)
|
|
18
18
|
|
|
@@ -54,9 +54,14 @@ export const CLIPS_HELP = `vidfarm raws — build & search a local raws library
|
|
|
54
54
|
|
|
55
55
|
raws list List raws in the library
|
|
56
56
|
--source <id> Only raws from one source video
|
|
57
|
+
--content-type <a,b> Only raws whose shot-KIND tag matches (comma-separated,
|
|
58
|
+
exact): talking_head, b_roll, product_shot, screen_recording,
|
|
59
|
+
demo, reaction, interview, establishing, lifestyle, text_graphic
|
|
60
|
+
--offset <n> Skip the first N (paginate with --limit)
|
|
57
61
|
--limit <n> | --json
|
|
58
62
|
|
|
59
63
|
raws search "<natural language>" Hybrid structured + semantic search
|
|
64
|
+
--content-type <a,b> Post-filter hits by shot-KIND tag (see \`raws list\`)
|
|
60
65
|
--limit <n> | --json | --gemini-key <key>
|
|
61
66
|
|
|
62
67
|
raws match "<text>" Top library raws to REPLACE a scene (vector search)
|
|
@@ -125,6 +130,8 @@ async function runScan(argv) {
|
|
|
125
130
|
aspect: { type: "string" },
|
|
126
131
|
"crop-focus": { type: "string" },
|
|
127
132
|
"no-text": { type: "boolean" },
|
|
133
|
+
"allow-text": { type: "boolean" },
|
|
134
|
+
"max-clips": { type: "string" },
|
|
128
135
|
"no-refine": { type: "boolean" },
|
|
129
136
|
cloud: { type: "boolean" },
|
|
130
137
|
url: { type: "string" },
|
|
@@ -153,16 +160,22 @@ async function runScan(argv) {
|
|
|
153
160
|
const rawWindows = values.range?.length
|
|
154
161
|
? parseTimeRanges(values.range)
|
|
155
162
|
: parsedPrompt.windows;
|
|
156
|
-
|
|
163
|
+
// Explicit flags / prompt hints win; otherwise fall back to the same
|
|
164
|
+
// unspecified-prompt product defaults (5–15s clips, vertical 9:16, avoid text,
|
|
165
|
+
// ~10 clips / 10 min). Opt out with `--aspect original` / `--allow-text`.
|
|
166
|
+
const explicitBand = values.duration
|
|
157
167
|
? resolveDurationBand(Number(values.duration))
|
|
158
168
|
: parsedPrompt.target_duration_sec != null
|
|
159
169
|
? resolveDurationBand(parsedPrompt.target_duration_sec)
|
|
160
170
|
: null;
|
|
161
|
-
const
|
|
162
|
-
|
|
171
|
+
const durationBand = explicitBand ?? { ...DEFAULT_CLIP_DURATION_BAND };
|
|
172
|
+
const explicitAspect = values.aspect
|
|
173
|
+
? (normalizeAspect(values.aspect) ?? (() => { throw new Error(`--aspect must be 9:16 | 16:9 | 4:3 | 1:1 | original (or vertical/horizontal/square), got "${values.aspect}"`); })())
|
|
163
174
|
: parsedPrompt.aspect;
|
|
175
|
+
const aspect = explicitAspect ?? DEFAULT_CLIP_ASPECT;
|
|
164
176
|
const cropFocus = normalizeCropFocus(values["crop-focus"]);
|
|
165
|
-
const avoidText = Boolean(values["no-text"]) || parsedPrompt.avoid_text;
|
|
177
|
+
const avoidText = values["allow-text"] ? false : (Boolean(values["no-text"]) || parsedPrompt.avoid_text || true);
|
|
178
|
+
const maxClips = values["max-clips"] ? Math.max(1, Math.round(Number(values["max-clips"]))) : parsedPrompt.max_clips ?? undefined;
|
|
166
179
|
const guidance = buildEffectiveGuidance({ contentPrompt: guidancePromptRaw, avoidText }) || undefined;
|
|
167
180
|
// ── BACKUP path: run the hunt on the deployed pipeline (--cloud) ──────────
|
|
168
181
|
if (values.cloud) {
|
|
@@ -181,7 +194,8 @@ async function runScan(argv) {
|
|
|
181
194
|
durationBand,
|
|
182
195
|
aspect,
|
|
183
196
|
cropFocus,
|
|
184
|
-
avoidText
|
|
197
|
+
avoidText,
|
|
198
|
+
maxClips
|
|
185
199
|
});
|
|
186
200
|
}
|
|
187
201
|
const videoArg = positionals[0];
|
|
@@ -224,6 +238,8 @@ async function runScan(argv) {
|
|
|
224
238
|
if (rawWindows.length && !windows.length) {
|
|
225
239
|
throw new Error("--range produced no usable window inside the video's duration.");
|
|
226
240
|
}
|
|
241
|
+
// Density/uniqueness target: ~10 clips / 10 min unless --max-clips / "N clips".
|
|
242
|
+
const targetClipCount = resolveTargetClipCount(effectiveHuntDurationSec(probe.duration_sec, windows), { max_clips: maxClips });
|
|
227
243
|
if (windows.length) {
|
|
228
244
|
console.log(`[raws] hunting only ${windows.map((w) => `${fmtClock(w.start_sec)}–${fmtClock(w.end_sec)}`).join(", ")} (${fmtDuration(windows.reduce((a, w) => a + w.end_sec - w.start_sec, 0))} of ${fmtDuration(probe.duration_sec)}).`);
|
|
229
245
|
}
|
|
@@ -234,6 +250,7 @@ async function runScan(argv) {
|
|
|
234
250
|
console.log(`[raws] cropping clips to ${aspect} (${cropFocus}).`);
|
|
235
251
|
if (avoidText)
|
|
236
252
|
console.log(`[raws] preferring scenes without on-screen text/captions (selection only — GhostCut is never run on the source).`);
|
|
253
|
+
console.log(`[raws] aiming for ~${targetClipCount} distinct raw${targetClipCount === 1 ? "" : "s"}${maxClips ? " (--max-clips)" : " (~10 / 10 min)"}.`);
|
|
237
254
|
console.log(`[raws] detecting scenes …`);
|
|
238
255
|
const detected = await detectScenes(videoPath, { durationSec: probe.duration_sec, windows, ...sceneOptions });
|
|
239
256
|
const scenes = fitScenesToDurationBand(detected, durationBand);
|
|
@@ -329,6 +346,7 @@ async function runScan(argv) {
|
|
|
329
346
|
probe,
|
|
330
347
|
windows,
|
|
331
348
|
durationBand,
|
|
349
|
+
targetClipCount,
|
|
332
350
|
sourceFilename: path.basename(videoPath),
|
|
333
351
|
concurrency,
|
|
334
352
|
onRefine: (r) => {
|
|
@@ -442,7 +460,10 @@ async function runScanCloud(input) {
|
|
|
442
460
|
...(input.durationBand ? { duration_band: input.durationBand } : {}),
|
|
443
461
|
...(input.aspect ? { aspect: input.aspect } : {}),
|
|
444
462
|
crop_focus: input.cropFocus,
|
|
445
|
-
|
|
463
|
+
// Send explicitly (incl. false) so --allow-text can override the
|
|
464
|
+
// server-side avoid-text default.
|
|
465
|
+
avoid_text: input.avoidText,
|
|
466
|
+
...(input.maxClips ? { max_clips: input.maxClips } : {})
|
|
446
467
|
}
|
|
447
468
|
})
|
|
448
469
|
});
|
|
@@ -510,13 +531,20 @@ async function runList(argv) {
|
|
|
510
531
|
args: argv,
|
|
511
532
|
options: {
|
|
512
533
|
source: { type: "string" },
|
|
534
|
+
"content-type": { type: "string" },
|
|
535
|
+
offset: { type: "string" },
|
|
513
536
|
limit: { type: "string" },
|
|
514
537
|
json: { type: "boolean" },
|
|
515
538
|
home: { type: "string" }
|
|
516
539
|
}
|
|
517
540
|
});
|
|
518
541
|
const store = new ClipStore(values.home);
|
|
519
|
-
const clips = store.listClips({
|
|
542
|
+
const clips = store.listClips({
|
|
543
|
+
sourceVideoId: values.source,
|
|
544
|
+
contentTypes: parseContentTypes(values["content-type"]),
|
|
545
|
+
offset: values.offset ? clampInt(values.offset, 0, 0, 1_000_000) : undefined,
|
|
546
|
+
limit: clampInt(values.limit, 50, 1, 5000)
|
|
547
|
+
});
|
|
520
548
|
store.close();
|
|
521
549
|
if (values.json) {
|
|
522
550
|
console.log(JSON.stringify(clips, null, 2));
|
|
@@ -538,6 +566,7 @@ async function runSearch(argv) {
|
|
|
538
566
|
allowPositionals: true,
|
|
539
567
|
options: {
|
|
540
568
|
provider: { type: "string" },
|
|
569
|
+
"content-type": { type: "string" },
|
|
541
570
|
limit: { type: "string" },
|
|
542
571
|
json: { type: "boolean" },
|
|
543
572
|
"gemini-key": { type: "string" },
|
|
@@ -549,6 +578,7 @@ async function runSearch(argv) {
|
|
|
549
578
|
const query = positionals.join(" ").trim();
|
|
550
579
|
if (!query)
|
|
551
580
|
throw new Error('Usage: vidfarm raws search "<natural language>"');
|
|
581
|
+
const contentTypes = parseContentTypes(values["content-type"]);
|
|
552
582
|
const limit = clampInt(values.limit, 10, 1, 200);
|
|
553
583
|
const store = new ClipStore(values.home);
|
|
554
584
|
if (store.stats().clips === 0) {
|
|
@@ -582,7 +612,13 @@ async function runSearch(argv) {
|
|
|
582
612
|
console.warn(`[raws] no ${provider} key or local agent CLI — structured/keyword search only.`);
|
|
583
613
|
}
|
|
584
614
|
const t0 = timestamp();
|
|
585
|
-
|
|
615
|
+
// Over-fetch when a content-type filter is on so the post-filter still fills
|
|
616
|
+
// the requested limit (the tag lives in tags_json, not the search index).
|
|
617
|
+
const searchLimit = contentTypes.length ? Math.min(1000, limit * 5) : limit;
|
|
618
|
+
let hits = store.search({ criteria, queryEmbedding, limit: searchLimit });
|
|
619
|
+
if (contentTypes.length) {
|
|
620
|
+
hits = hits.filter((h) => (h.clip.tags.content_type ?? []).some((t) => contentTypes.includes(String(t).toLowerCase()))).slice(0, limit);
|
|
621
|
+
}
|
|
586
622
|
const ms = elapsedMs(t0);
|
|
587
623
|
store.close();
|
|
588
624
|
if (values.json) {
|
|
@@ -895,6 +931,13 @@ function normalizeProvider(value) {
|
|
|
895
931
|
return value;
|
|
896
932
|
throw new Error(`--provider must be gemini | openai | openrouter (got "${value}")`);
|
|
897
933
|
}
|
|
934
|
+
// Parse a comma-separated --content-type flag into lowercase shot-KIND tags for
|
|
935
|
+
// the tags.content_type filter (talking_head, b_roll, product_shot, …).
|
|
936
|
+
function parseContentTypes(value) {
|
|
937
|
+
if (!value)
|
|
938
|
+
return [];
|
|
939
|
+
return value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
940
|
+
}
|
|
898
941
|
function normalizeTier(value) {
|
|
899
942
|
if (value === "flash")
|
|
900
943
|
return "flash";
|