@officexapp/vidfarm-devcli 0.21.14 → 0.21.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -27,7 +27,7 @@ import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
27
27
  import { resolveLocalDataDir, localBackendAvailable, LocalModeUnavailableError, localApiRequest } from "./devcli/local-backend.js";
28
28
  import { startLocalFrontendServer, serveShellsPresent } from "./devcli/local-frontend-server.js";
29
29
  import { readStoredAuth, writeStoredAuth, clearStoredAuth, hostsMatch } from "./devcli/auth-store.js";
30
- import { COST_MODES, CostModeBlockedError, assertBilledAllowed, clearStoredCostMode, costModeExplainer, costModeSummaryLine, normalizeCostMode, resolveCostMode, writeStoredCostMode, COST_MODE_BLURB } from "./devcli/cost-mode.js";
30
+ import { CostModeBlockedError, assertBilledAllowed, clearStoredCostMode, costModeExplainer, costModeSummaryLine, normalizeCostMode, resolveCostMode, writeStoredCostMode, COST_MODE_BLURB, COST_MODE_DISPLAY_LIST, costModeDisplayName } from "./devcli/cost-mode.js";
31
31
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
32
32
  // `serve` command boots the FULL editor locally (single origin, disk-backed
33
33
  // records + storage) so power users edit compositions on disk while a browser
@@ -71,7 +71,7 @@ Account (persisted login — points the CLI + \`serve\` at cloud prod):
71
71
  logout Clear the persisted credential
72
72
  whoami Show the logged-in account, host, plan, and cost mode
73
73
  cost-mode [mode] Show or set the money-saving preference all billed
74
- commands respect: minimize | hybrid | pure-ai.
74
+ commands respect: minimize | hybrid | rich-ai.
75
75
  No arg = show current + explain the three simply.
76
76
  --clear forgets it · --note "<why>" annotates the save.
77
77
  Override per-run with --cost-mode <m> / VIDFARM_COST_MODE;
@@ -632,10 +632,10 @@ Cost spectrum (default to the cheapest approach that works; see SKILL.director.m
632
632
  $10+ Heavy AI generation (many/long AI clips, custom characters).
633
633
  Notes: image gen is cheap (use freely); AI VIDEO gen is expensive (ask the user
634
634
  first). Decompose is a one-time ~$0.10 — or skip it by forking a decomposed template.
635
- Set 'vidfarm cost-mode <minimize|hybrid|pure-ai>' once and every billed command
635
+ Set 'vidfarm cost-mode <minimize|hybrid|rich-ai>' once and every billed command
636
636
  (generate, music, decompose, cloud render/TTS/STT/greenscreen, create, replicate)
637
637
  respects it: minimize refuses billed spend without --yes and points you at the free
638
- local path; hybrid/pure-ai run but print each op's cost. FREE local engines never gate
638
+ local path; hybrid/rich-ai run but print each op's cost. FREE local engines never gate
639
639
  (local render, tts --engine local, stt --engine whisper, remove-greenscreen --local).
640
640
 
641
641
  Escape hatch — call ANY route directly:
@@ -2974,11 +2974,218 @@ async function runForkCommand(argv) {
2974
2974
  const tId = result.json?.template_id ?? templateId;
2975
2975
  emitResult(result, ctx.json, [["Open editor ", forkId ? editorFrontendUrl(ctx.host, tId, forkId) : null]]);
2976
2976
  }
2977
+ // A weak, UNLICENSED fallback guide for free-tier local decompose. It deliberately
2978
+ // omits Vidfarm's proprietary methodology (that lives in the paid HARNESS.md) and
2979
+ // tells the agent to produce a best-effort decomposition with open reasoning only.
2980
+ const WEAK_FREE_DECOMPOSE_GUIDE = `# Local Decompose — Free Tier (weak, unlicensed)
2981
+
2982
+ You are on Vidfarm's FREE tier. This is a stripped-down, best-effort decompose guide.
2983
+ It does NOT include Vidfarm's licensed decomposition methodology (viral-DNA extraction,
2984
+ editor/replication harnesses, the generative character-card→storyboard→animate
2985
+ workflow). Expect noticeably weaker results than Vidfarm cloud decompose, and expect
2986
+ to burn more of your own desktop-agent tokens iterating to something usable.
2987
+
2988
+ Do your honest best from first principles: watch the source, split it into scenes with
2989
+ timestamps, transcribe any on-screen text into captions, and write a short summary.
2990
+ Fill \`smart-decompose.template.json\` as far as you can — leave the harness/viral-DNA
2991
+ sub-objects mostly empty; you are not licensed to Vidfarm's method for those.
2992
+
2993
+ To get the FULL licensed harness (much better results, runs on your own tokens, and
2994
+ lets you sync the decomposition back to the shared library so others reuse it free),
2995
+ subscribe to Vidfarm and re-run \`vidfarm decompose <forkId> --local\`.
2996
+ `;
2997
+ // The result skeleton the desktop agent fills in and drops at smart-decompose.json.
2998
+ // Kept intentionally sparse — the field docs live in HARNESS.md (paid) or the weak
2999
+ // guide (free); this is just the shape the sync endpoint coerces.
3000
+ const LOCAL_DECOMPOSE_RESULT_TEMPLATE = JSON.stringify({
3001
+ summary: "",
3002
+ durationSeconds: 0,
3003
+ width: 720,
3004
+ height: 1280,
3005
+ scenes: [{ slug: "scene_1", start: 0, duration: 0, label: "", description: "", viral_note: "" }],
3006
+ captions: [{ slug: "caption_1", text: "", start: 0, duration: 0, x: 8, y: 68, width: 84, height: 12, font_size: 0, color: "#ffffff", background: "#000000", background_style: "highlight-translucent", font_family: "", font_weight: 700, viral_note: "" }],
3007
+ viralDna: {},
3008
+ editorHarness: {},
3009
+ replicationHarness: { generative_workflow: { applies: false, steps: [], character_card: {}, storyboard: {}, animation: { default_motion: "ken_burns_static", scenes: [] } } },
3010
+ transcript: null
3011
+ }, null, 2);
3012
+ function buildLocalDecomposeTask(input) {
3013
+ const syncLine = input.paid
3014
+ ? `4. Sync it back to cloud (shared library): \`vidfarm decompose ${input.forkId} --local --sync --dir ${input.dir}\``
3015
+ : `4. (Sync to cloud is PAID-only. Your free decomposition stays local. Subscribe to contribute it back.)`;
3016
+ return `# Decompose task — fork ${input.forkId}
3017
+
3018
+ You are the desktop AI agent. Run this decompose on your OWN tokens.
3019
+
3020
+ Source video: ${input.sourceUrl || "(unknown — pass --source <url> or read it from cloud-video-context.json)"}
3021
+ ${input.contextNote ? `\n${input.contextNote}\n` : ""}
3022
+ Steps:
3023
+ 1. Read ${input.harnessSaved ? "**HARNESS.md** in this folder — it is Vidfarm's LICENSED decomposition methodology (paid). Follow it exactly, including the character-card → storyboard → (ken-burns vs AI-video) generative workflow detection." : "**HARNESS.md** in this folder (free-tier weak guide) and do your honest best."}
3024
+ 2. Analyze the source video (sample frames, read on-screen text, transcribe audio if any).
3025
+ 3. Produce **smart-decompose.json** in this folder, matching the shape in smart-decompose.template.json${input.harnessSaved ? " and every rule in HARNESS.md" : ""}. Fill scenes + captions with real timestamps; ${input.harnessSaved ? "fully populate viralDna, editorHarness, and replicationHarness (including replicationHarness.generative_workflow when the template fits the image→video pipeline)." : "fill what you can."}
3026
+ ${syncLine}
3027
+
3028
+ Do NOT invent footage or audio you cannot see/hear. Ground every scene and caption on a real moment.
3029
+ `;
3030
+ }
3031
+ // Resolve the on-disk staging folder for a local decompose (next to the CWD so the
3032
+ // desktop agent sees the harness, the task, and drops its smart-decompose.json here).
3033
+ function resolveLocalDecomposeDir(forkId, dirFlag) {
3034
+ const raw = typeof dirFlag === "string" && dirFlag.trim() ? dirFlag.trim() : `vidfarm-decompose-${forkId}`;
3035
+ return path.resolve(process.cwd(), raw);
3036
+ }
3037
+ // Read the caller's paid/subscription status from the self endpoint. Used to gate
3038
+ // the licensed harness fetch and to warn free users before a weak local run.
3039
+ async function fetchCustomerPaidStatus(ctx) {
3040
+ try {
3041
+ const res = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me", auth: ctx.auth });
3042
+ if (!res.ok || !res.json || typeof res.json !== "object")
3043
+ return { ok: false, paid: false, email: null, planTier: null };
3044
+ const customer = res.json.customer ?? {};
3045
+ return {
3046
+ ok: true,
3047
+ paid: Boolean(customer.isPaidPlan),
3048
+ email: typeof customer.email === "string" ? customer.email : null,
3049
+ planTier: typeof customer.planTier === "string" ? customer.planTier : null
3050
+ };
3051
+ }
3052
+ catch {
3053
+ return { ok: false, paid: false, email: null, planTier: null };
3054
+ }
3055
+ }
3056
+ // Local decompose — the paid-vs-free, prepare-vs-sync workflow behind
3057
+ // `vidfarm decompose <forkId> --local`. PREPARE stages the harness + task + result
3058
+ // skeleton for the desktop agent; SYNC (--sync) posts the agent's produced
3059
+ // smart-decompose.json back to cloud (paid-only). Free users get a weak, unlicensed,
3060
+ // local-only run with an upgrade nudge — so the ethics/licensing live in the tool,
3061
+ // not just the doc, and a customer's agent complies by default.
3062
+ async function runLocalDecomposeCommand(forkId, values) {
3063
+ const ctx = commonContext(values);
3064
+ const dir = resolveLocalDecomposeDir(forkId, values.dir);
3065
+ const resultPath = path.join(dir, "smart-decompose.json");
3066
+ // -------- SYNC phase: push the agent's local result back to the shared library.
3067
+ if (values.sync) {
3068
+ if (!existsSync(resultPath)) {
3069
+ throw new Error(`No decomposition to sync at ${resultPath}. Run \`vidfarm decompose ${forkId} --local\` first, have your agent produce smart-decompose.json, then re-run with --sync.`);
3070
+ }
3071
+ let result;
3072
+ try {
3073
+ result = JSON.parse(readFileSync(resultPath, "utf8"));
3074
+ }
3075
+ catch (e) {
3076
+ throw new Error(`smart-decompose.json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
3077
+ }
3078
+ const res = await apiRequest({
3079
+ method: "POST",
3080
+ host: ctx.host,
3081
+ path: `/api/v1/compositions/${encodeURIComponent(forkId)}/auto-decompose/sync`,
3082
+ auth: ctx.auth,
3083
+ body: {
3084
+ result,
3085
+ source_url: typeof values.source === "string" && values.source.trim() ? values.source.trim() : undefined,
3086
+ provider: "local",
3087
+ model: process.env.VIDFARM_LOCAL_DECOMPOSE_MODEL || "desktop-agent"
3088
+ }
3089
+ });
3090
+ if (res.status === 402) {
3091
+ throw new Error("Syncing a local decompose to cloud requires a PAID Vidfarm subscription — the shared template library is a paid, licensed asset. Subscribe to contribute this back, or keep the decomposition local-only.");
3092
+ }
3093
+ assertApiOk(res, "decompose --local --sync");
3094
+ if (ctx.json) {
3095
+ emitResult(res, true);
3096
+ return;
3097
+ }
3098
+ const j = (res.json ?? {});
3099
+ console.log(`${GREEN}Synced decomposition to cloud.${RESET} fork=${j.fork_id ?? forkId} scenes=${j.scene_count ?? "?"} captions=${j.caption_count ?? "?"}${j.generative_workflow_applies ? ` ${BOLD}generative_workflow: applies${RESET}` : ""}`);
3100
+ console.log(`${DIM}The next creator who forks this template gets your decomposition for free.${RESET}`);
3101
+ return;
3102
+ }
3103
+ // -------- PREPARE phase: stage inputs for the desktop agent.
3104
+ const paid = await fetchCustomerPaidStatus(ctx);
3105
+ mkdirSync(dir, { recursive: true });
3106
+ // Pull any existing cloud grounding for the fork (source URL + prior context).
3107
+ let sourceUrl = typeof values.source === "string" && values.source.trim() ? values.source.trim() : "";
3108
+ let contextNote = "";
3109
+ try {
3110
+ const ctxRes = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/video-context.json`, auth: ctx.auth });
3111
+ if (ctxRes.ok && ctxRes.json && typeof ctxRes.json === "object") {
3112
+ writeFileSync(path.join(dir, "cloud-video-context.json"), JSON.stringify(ctxRes.json, null, 2));
3113
+ const scloud = ctxRes.json.source_url;
3114
+ if (!sourceUrl && typeof scloud === "string" && scloud.trim())
3115
+ sourceUrl = scloud.trim();
3116
+ contextNote = "A prior cloud context was saved to cloud-video-context.json — reuse it where useful.";
3117
+ }
3118
+ }
3119
+ catch {
3120
+ /* fork may not be decomposed yet — fine */
3121
+ }
3122
+ // Fetch the LICENSED harness (paid only). Free users get the weak, unlicensed guide.
3123
+ let harnessSaved = false;
3124
+ if (paid.paid) {
3125
+ try {
3126
+ const hRes = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/decompose/harness.md", auth: ctx.auth });
3127
+ if (hRes.ok && typeof hRes.text === "string" && hRes.text.trim().length > 0) {
3128
+ writeFileSync(path.join(dir, "HARNESS.md"), hRes.text);
3129
+ harnessSaved = true;
3130
+ }
3131
+ }
3132
+ catch {
3133
+ /* fall through to the weak guide */
3134
+ }
3135
+ }
3136
+ if (!harnessSaved) {
3137
+ writeFileSync(path.join(dir, "HARNESS.md"), WEAK_FREE_DECOMPOSE_GUIDE);
3138
+ }
3139
+ writeFileSync(path.join(dir, "smart-decompose.template.json"), LOCAL_DECOMPOSE_RESULT_TEMPLATE);
3140
+ writeFileSync(path.join(dir, "DECOMPOSE_TASK.md"), buildLocalDecomposeTask({ forkId, dir, sourceUrl, paid: paid.paid, harnessSaved, contextNote }));
3141
+ if (ctx.json) {
3142
+ emitResult({
3143
+ status: 200,
3144
+ ok: true,
3145
+ json: {
3146
+ ok: true,
3147
+ prepared: true,
3148
+ dir,
3149
+ paid: paid.paid,
3150
+ harness: harnessSaved ? "licensed" : "weak-free",
3151
+ source_url: sourceUrl || null,
3152
+ task: path.join(dir, "DECOMPOSE_TASK.md"),
3153
+ result_expected: resultPath,
3154
+ sync_cmd: paid.paid ? `vidfarm decompose ${forkId} --local --sync --dir ${dir}` : null
3155
+ },
3156
+ text: ""
3157
+ }, true);
3158
+ return;
3159
+ }
3160
+ if (paid.paid) {
3161
+ console.log(`${GREEN}Local decompose prepared (PAID — latest licensed harness fetched).${RESET}`);
3162
+ }
3163
+ else {
3164
+ console.log(`${RED}Local decompose prepared (FREE tier — WEAK, UNLICENSED).${RESET}`);
3165
+ console.log(`${DIM}Heads up: the free local decompose uses a stripped-down method and your OWN desktop-agent tokens. It typically produces WORSE viral-DNA / harness quality than Vidfarm cloud AND burns more tokens iterating. Cloud decompose (\`vidfarm decompose ${forkId}\`) is ~$0.10 one-time, runs Vidfarm's best models, and its result is shared free with the network. You also CANNOT sync a free local decompose back to cloud.${RESET}`);
3166
+ console.log(`${DIM}Subscribe to run the licensed harness locally on your own tokens AND contribute your decompositions back to the shared library.${RESET}`);
3167
+ }
3168
+ console.log("");
3169
+ console.log(` 1. Open ${BOLD}${path.join(dir, "DECOMPOSE_TASK.md")}${RESET} and follow it (your desktop AI agent does the analysis).`);
3170
+ console.log(` 2. Your agent writes ${BOLD}${resultPath}${RESET} matching smart-decompose.template.json${harnessSaved ? " + HARNESS.md" : ""}.`);
3171
+ if (paid.paid)
3172
+ console.log(` 3. Sync back: ${BOLD}vidfarm decompose ${forkId} --local --sync --dir ${dir}${RESET}`);
3173
+ else
3174
+ console.log(` 3. ${DIM}(Sync to cloud is paid-only — subscribe to contribute this back.)${RESET}`);
3175
+ }
2977
3176
  async function runDecomposeCommand(argv) {
2978
- const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), mode: { type: "string", default: "smart" }, prompt: { type: "string" } } });
3177
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), mode: { type: "string", default: "smart" }, prompt: { type: "string" }, sync: { type: "boolean", default: false }, dir: { type: "string" }, source: { type: "string" } } });
2979
3178
  const forkId = parsed.positionals[0];
2980
3179
  if (!forkId)
2981
3180
  throw new Error("decompose requires a fork id.");
3181
+ // `--local` runs decompose on the user's OWN desktop AI-agent tokens instead of
3182
+ // billing Vidfarm cloud. Paid users pull the latest licensed harness + can sync
3183
+ // the result back to the shared library; free users get a weaker, unlicensed,
3184
+ // local-only path (with an upgrade nudge). See runLocalDecomposeCommand.
3185
+ if (parsed.values.local) {
3186
+ await runLocalDecomposeCommand(forkId, parsed.values);
3187
+ return;
3188
+ }
2982
3189
  const ctx = commonContext(parsed.values);
2983
3190
  guardBilled(ctx, {
2984
3191
  label: "AI decompose (scene split + annotations)",
@@ -6026,12 +6233,12 @@ async function runWhoamiCommand(argv) {
6026
6233
  console.log(`${GREEN}${BOLD}${who}${RESET} → ${ctx.host}`);
6027
6234
  console.log(` plan ${customer.isPaidPlan ? `${GREEN}paid${plan}${RESET}` : `${DIM}free${plan}${RESET}`}`);
6028
6235
  console.log(` credential ${DIM}${stored ? "persisted login (vidfarm login)" : "env/flag key — not persisted (run vidfarm login to persist)"}${RESET}`);
6029
- console.log(` ${costModeSummaryLine(ctx.costMode).replace("cost mode: ", "cost mode ")}${!ctx.costMode.isSet ? ` ${DIM}(set with: vidfarm cost-mode <minimize|hybrid|pure-ai>)${RESET}` : ""}`);
6236
+ console.log(` ${costModeSummaryLine(ctx.costMode).replace("cost mode: ", "cost mode ")}${!ctx.costMode.isSet ? ` ${DIM}(set with: vidfarm cost-mode <minimize|hybrid|rich-ai>)${RESET}` : ""}`);
6030
6237
  return;
6031
6238
  }
6032
6239
  emitResult(result, ctx.json);
6033
6240
  }
6034
- // `vidfarm cost-mode [minimize|hybrid|pure-ai]` — show or set the money-saving
6241
+ // `vidfarm cost-mode [minimize|hybrid|rich-ai]` — show or set the money-saving
6035
6242
  // preference every billed command respects. No arg = show current + explain the
6036
6243
  // three simply so an agent can relay them to the user (and be reminded to ask
6037
6244
  // about saving the choice into whatever agent memory it has). `--clear` forgets it.
@@ -6054,14 +6261,14 @@ async function runCostModeCommand(argv) {
6054
6261
  if (requested) {
6055
6262
  const mode = normalizeCostMode(requested);
6056
6263
  if (!mode) {
6057
- throw new Error(`Unknown cost mode "${requested}". Choose one of: ${COST_MODES.join(", ")}.`);
6264
+ throw new Error(`Unknown cost mode "${requested}". Choose one of: ${COST_MODE_DISPLAY_LIST.join(", ")}.`);
6058
6265
  }
6059
6266
  // Stamp time on the host so this stays deterministic-friendly for scripts.
6060
6267
  const savedAt = new Date().toISOString();
6061
6268
  const file = writeStoredCostMode(mode, savedAt, parsed.values.note ?? null, home);
6062
6269
  if (json)
6063
6270
  return printJson({ ok: true, cost_mode: mode, saved_at: savedAt, file });
6064
- console.log(`${GREEN}${BOLD}Cost mode: ${mode}${RESET}`);
6271
+ console.log(`${GREEN}${BOLD}Cost mode: ${costModeDisplayName(mode)}${RESET}`);
6065
6272
  console.log(`${DIM}${COST_MODE_BLURB[mode]}${RESET}`);
6066
6273
  console.log(`${DIM}Saved to ${file} (every billed command now respects it).${RESET}`);
6067
6274
  console.log(`${DIM}Tip: if the user wants this remembered across sessions, offer to also save it to your` +
@@ -6077,7 +6284,7 @@ async function runCostModeCommand(argv) {
6077
6284
  console.log("");
6078
6285
  console.log(costModeExplainer());
6079
6286
  console.log("");
6080
- console.log(`${DIM}Set it: ${BOLD}vidfarm cost-mode <minimize|hybrid|pure-ai>${RESET}`);
6287
+ console.log(`${DIM}Set it: ${BOLD}vidfarm cost-mode <minimize|hybrid|rich-ai>${RESET}`);
6081
6288
  console.log(`${DIM}Forget it: vidfarm cost-mode --clear · override per-run: --cost-mode <m> or VIDFARM_COST_MODE.${RESET}`);
6082
6289
  if (!resolved.isSet) {
6083
6290
  console.log(`${DIM}Nothing saved yet — ask the user which one they want before spending AI credits.${RESET}`);
@@ -10,7 +10,7 @@
10
10
  // - hybrid : free where it's free, spend on AI only where it clearly wins.
11
11
  // (default recommendation.) Billed ops run but print a cost line.
12
12
  // - pure-ai : best-quality; AI image/video/voice/music used freely. Billed
13
- // ops run; cost is still surfaced.
13
+ // ops run; cost is still surfaced. Shown to humans as "rich-ai".
14
14
  //
15
15
  // This module lives in the CLI's static import closure and is BACKEND-FREE (only
16
16
  // Node built-ins) so it ships in the public cloud-only package.
@@ -33,10 +33,25 @@ export function normalizeCostMode(raw) {
33
33
  return "minimize";
34
34
  if (["hybrid", "mixed", "balanced", "smart", "default"].includes(v))
35
35
  return "hybrid";
36
- if (["pure-ai", "pureai", "ai", "best", "best-quality", "premium", "max"].includes(v))
36
+ if (["pure-ai", "pureai", "rich-ai", "richai", "rich", "ai", "best", "best-quality", "premium", "max"].includes(v))
37
37
  return "pure-ai";
38
38
  return null;
39
39
  }
40
+ /**
41
+ * Human-facing NAME for a mode. The canonical stored slug for best-quality stays
42
+ * `pure-ai` (so saved cost-mode.json, --cost-mode flags, and VIDFARM_COST_MODE
43
+ * keep resolving), but users see and can type "rich-ai".
44
+ */
45
+ export const COST_MODE_DISPLAY = {
46
+ minimize: "minimize",
47
+ hybrid: "hybrid",
48
+ "pure-ai": "rich-ai"
49
+ };
50
+ /** The three modes as users see them, e.g. for "<minimize|hybrid|rich-ai>". */
51
+ export const COST_MODE_DISPLAY_LIST = COST_MODES.map((m) => COST_MODE_DISPLAY[m]);
52
+ export function costModeDisplayName(mode) {
53
+ return COST_MODE_DISPLAY[mode];
54
+ }
40
55
  /** Read the persisted cost mode, or null if absent/unparseable. */
41
56
  export function readStoredCostMode(home) {
42
57
  try {
@@ -96,13 +111,13 @@ export const COST_MODE_BLURB = {
96
111
  hybrid: "Hybrid (recommended) — free where it's free, spend AI credits only where they " +
97
112
  "clearly win (a hero shot, a voice you can't fake locally). Billed ops run but " +
98
113
  "each prints its cost so nothing is a surprise.",
99
- "pure-ai": "Pure AI — best quality; AI image/video/voice/music used freely. Billed ops run " +
114
+ "pure-ai": "Rich AI — best quality; AI image/video/voice/music used freely. Billed ops run " +
100
115
  "without gating; cost is still shown."
101
116
  };
102
117
  /** One short human line summarizing the active mode. */
103
118
  export function costModeSummaryLine(resolved) {
104
119
  const setNote = resolved.isSet ? `set via ${resolved.source}` : "NOT set — assuming hybrid";
105
- return `cost mode: ${resolved.mode} (${setNote})`;
120
+ return `cost mode: ${costModeDisplayName(resolved.mode)} (${setNote})`;
106
121
  }
107
122
  /** The 3-line "explain it simply" block an agent should relay to the user. */
108
123
  export function costModeExplainer() {
@@ -110,7 +125,7 @@ export function costModeExplainer() {
110
125
  "How much do you want Vidfarm to spend on AI credits?",
111
126
  " • minimize — cheapest: free local compute + free stock media, no surprise AI spend.",
112
127
  " • hybrid — recommended: free where free, pay AI only where it clearly wins.",
113
- " • pure-ai — best quality: use AI image/video/voice/music freely.",
128
+ " • rich-ai — best quality: use AI image/video/voice/music freely.",
114
129
  "Tip: before paying to generate music/SFX/images/video, try the free stock catalog —",
115
130
  ' vidfarm media search "<meaning>" --type bgm|sfx|image|vector|icon|video.',
116
131
  " Check the keyless sources first — Openverse (CC/CC0 music, SFX, images) and iconify (icons)",
@@ -150,9 +165,9 @@ export function assertBilledAllowed(input) {
150
165
  return;
151
166
  if (!resolved.isSet) {
152
167
  log(`[cost] No spend preference set — ${label}${cost} will bill the account. ` +
153
- `Ask the user minimize / hybrid / pure-ai and save it: vidfarm cost-mode <choice>.`);
168
+ `Ask the user minimize / hybrid / rich-ai and save it: vidfarm cost-mode <choice>.`);
154
169
  return;
155
170
  }
156
- log(`[cost] ${label}${cost} — billed (mode: ${resolved.mode}${yes ? ", confirmed" : ""}).`);
171
+ log(`[cost] ${label}${cost} — billed (mode: ${costModeDisplayName(resolved.mode)}${yes ? ", confirmed" : ""}).`);
157
172
  }
158
173
  //# sourceMappingURL=cost-mode.js.map
@@ -705,7 +705,7 @@ function layerStyle(layer) {
705
705
  }
706
706
  if (["caption", "text", "shape", "html"].includes(layer.kind)) {
707
707
  const fontFamily = layer.fontFamily || "TikTok Sans";
708
- styles.push("display:flex", "align-items:center", "justify-content:center", "padding:3px", `font-family:${fontCssFamily(fontFamily)}, 'TikTok Sans', Montserrat, Abel, sans-serif`, `font-weight:${positiveInteger(layer.fontWeight, 700)}`, `line-height:${positiveNumber(layer.lineHeight, 1.18)}`, "text-align:center", "text-transform:none", `font-size:${positiveInteger(layer.fontSize, 32)}px`, `color:${layer.color || "#ffffff"}`, layer.kind === "shape" && layer.textBackgroundStyle === "panel"
708
+ styles.push("display:flex", "align-items:center", "justify-content:center", "padding:3px", `font-family:${fontCssFamily(fontFamily)}, 'TikTok Sans', Montserrat, Abel, sans-serif, 'Noto Color Emoji'`, `font-weight:${positiveInteger(layer.fontWeight, 700)}`, `line-height:${positiveNumber(layer.lineHeight, 1.18)}`, "text-align:center", "text-transform:none", `font-size:${positiveInteger(layer.fontSize, 32)}px`, `color:${layer.color || "#ffffff"}`, layer.kind === "shape" && layer.textBackgroundStyle === "panel"
709
709
  ? `background:${layer.background || "transparent"}`
710
710
  : "background:transparent");
711
711
  }
@@ -733,7 +733,7 @@ function textInlineCss(style, color, background, fontFamily, fontWeight) {
733
733
  else if (style === "highlight-translucent") {
734
734
  styles.push("padding:0.07em 0.46em 0.09em", "border-radius:0.32em", `background:${rgbaFromColor(background, 0.34)}`, "text-shadow:none", "-webkit-text-stroke:0 transparent");
735
735
  }
736
- styles.push(`font-family:${fontCssFamily(fontFamily)}, 'TikTok Sans', sans-serif`);
736
+ styles.push(`font-family:${fontCssFamily(fontFamily)}, 'TikTok Sans', sans-serif, 'Noto Color Emoji'`);
737
737
  styles.push(`font-weight:${fontWeight}`);
738
738
  styles.push(`color:${color}`);
739
739
  return styles.join(";");
@@ -20,15 +20,16 @@ export const DEFAULT_CLIPS_PER_10_MIN = 10;
20
20
  export const DEFAULT_CLIP_ASPECT = "9:16";
21
21
  /**
22
22
  * Fill the unspecified-prompt defaults on a normalized hunt spec: a 5–15s
23
- * duration band, vertical 9:16 crop, and avoid-text ON. Only fills gaps — an
24
- * explicit user choice (including avoid_text:false) is preserved.
23
+ * duration band and a vertical 9:16 crop. avoid-text is deliberately NOT
24
+ * defaulted on most social sources (TikTok/Shorts/Reels) are captioned
25
+ * wall-to-wall, so an implicit avoid_text:true rejects EVERY scene and yields a
26
+ * silent 0-clip hunt. avoid_text stays opt-in (set true only when the user asks
27
+ * to avoid on-screen text); an explicit choice either way is preserved.
25
28
  */
26
29
  export function applyHuntSpecDefaults(spec) {
27
30
  const out = { ...spec };
28
31
  if (!out.duration_band)
29
32
  out.duration_band = { ...DEFAULT_CLIP_DURATION_BAND };
30
- if (out.avoid_text === undefined)
31
- out.avoid_text = true;
32
33
  if (!out.aspect)
33
34
  out.aspect = DEFAULT_CLIP_ASPECT;
34
35
  return out;
@@ -103,50 +103,96 @@ export function pickBestVideoMedia(medias) {
103
103
  // bare programmatic fetches, and the top-res server-side "merge" rendition
104
104
  // transiently 403s while the merge runs or when rate-limited.
105
105
  const MEDIA_DOWNLOAD_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
106
- const RETRYABLE_DOWNLOAD_STATUS = new Set([403, 408, 425, 429, 500, 502, 503, 504]);
106
+ // Retryable within a round only for genuinely transient failures. A bot-challenge
107
+ // (Cloudflare "Just a moment…") is NOT retryable in-round — the same signed URL
108
+ // stays gated; only a fresh re-lookup (new token/proxy edge) can clear it.
109
+ const RETRYABLE_DOWNLOAD_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
107
110
  function delay(ms) {
108
111
  return new Promise((resolve) => setTimeout(resolve, ms));
109
112
  }
113
+ /**
114
+ * A response that is HTML (or an explicit anti-bot challenge) is NOT the video —
115
+ * the snapvideo.co CDN hosts sit behind Cloudflare, which answers server-side
116
+ * fetches with a `text/html` "Just a moment…" interstitial (usually 403, but it
117
+ * can also come back 200). Streaming that to disk would save a bogus "video", so
118
+ * we treat any HTML body as a download failure regardless of status code.
119
+ */
120
+ function looksLikeBotChallenge(response) {
121
+ const ct = (response.headers.get("content-type") || "").toLowerCase();
122
+ if (ct.includes("text/html") || ct.includes("application/xhtml"))
123
+ return true;
124
+ // Cloudflare stamps its edge on the challenge response.
125
+ const cfMitigated = (response.headers.get("cf-mitigated") || "").toLowerCase();
126
+ return cfMitigated.includes("challenge");
127
+ }
110
128
  /**
111
129
  * Download the first playable rendition that actually responds, walking the
112
130
  * ranked candidates highest-res first. The RapidAPI resolver returns SEVERAL
113
131
  * copies it hosts itself (e.g. a progressive 360p plus server-side-merged
114
- * 720p/1080p); the merge renditions can transiently 403 while merging or when
115
- * rate-limited, so we retry a rendition once and then fall back to the next copy
116
- * rather than hard-failing the whole ingest. Returns the live Response (caller
117
- * streams `response.body` to disk/S3) plus the winning candidate. Throws only if
118
- * EVERY rendition is unreachable, with each failure in the message.
132
+ * 720p/1080p). The merge renditions sit behind a Cloudflare bot-challenge that a
133
+ * Lambda fetch can't clear, and even the progressive proxy gets *intermittently*
134
+ * challenged so a single lookup can momentarily return an all-gated set. We
135
+ * therefore (a) reject HTML/challenge bodies instead of saving them as video,
136
+ * (b) fall back across renditions within a lookup, and (c) when `reResolve` is
137
+ * supplied, re-run the resolver for a FRESH signed set (new token + proxy edge,
138
+ * which is what actually clears a transient full-gate) and walk it again.
139
+ * Returns the live Response (caller streams `response.body` to disk/S3) plus the
140
+ * winning candidate. Throws only if every rendition across every round is
141
+ * unreachable, with each failure in the message.
119
142
  */
120
- export async function fetchFirstDownloadableMedia(medias) {
121
- const ranked = rankPlayableVideoMedias(medias);
122
- if (!ranked.length) {
123
- throw new Error("Video download lookup returned no playable MP4 media URL.");
124
- }
143
+ export async function fetchFirstDownloadableMedia(medias, opts = {}) {
144
+ const { reResolve } = opts;
145
+ const maxRounds = reResolve ? 3 : 1;
125
146
  const failures = [];
126
- for (const media of ranked) {
127
- const url = media.url;
128
- if (!url)
129
- continue;
130
- const label = media.quality ?? media.label ?? "?";
131
- for (let attempt = 0; attempt < 2; attempt++) {
132
- if (attempt > 0)
133
- await delay(750);
147
+ let current = medias;
148
+ let lastRankedCount = 0;
149
+ for (let round = 0; round < maxRounds; round++) {
150
+ if (round > 0) {
151
+ // Back off, then mint a fresh signed set — the only thing that recovers a
152
+ // transient Cloudflare gate on the CDN hosts.
153
+ await delay(1000 * round);
134
154
  try {
135
- const response = await fetch(url, { headers: { "user-agent": MEDIA_DOWNLOAD_UA, accept: "*/*" } });
136
- if (response.ok && response.body) {
137
- return { response, media };
138
- }
139
- failures.push(`${label}:HTTP ${response.status}`);
140
- await response.body?.cancel?.().catch(() => { });
141
- // A non-transient status won't fix itself on retry — move to the next copy.
142
- if (!RETRYABLE_DOWNLOAD_STATUS.has(response.status))
143
- break;
155
+ current = await reResolve();
144
156
  }
145
157
  catch (error) {
146
- failures.push(`${label}:${error instanceof Error ? error.message : String(error)}`);
158
+ failures.push(`re-lookup:${error instanceof Error ? error.message : String(error)}`);
159
+ break;
160
+ }
161
+ }
162
+ const ranked = rankPlayableVideoMedias(current);
163
+ lastRankedCount = ranked.length;
164
+ if (!ranked.length) {
165
+ failures.push(round === 0 ? "no playable media in lookup" : `round ${round + 1}: no playable media`);
166
+ continue;
167
+ }
168
+ for (const media of ranked) {
169
+ const url = media.url;
170
+ if (!url)
171
+ continue;
172
+ const label = media.quality ?? media.label ?? "?";
173
+ for (let attempt = 0; attempt < 2; attempt++) {
174
+ if (attempt > 0)
175
+ await delay(750);
176
+ try {
177
+ const response = await fetch(url, { headers: { "user-agent": MEDIA_DOWNLOAD_UA, accept: "*/*" } });
178
+ const challenged = looksLikeBotChallenge(response);
179
+ if (response.ok && response.body && !challenged) {
180
+ return { response, media };
181
+ }
182
+ failures.push(`${label}:HTTP ${response.status}${challenged ? " bot-challenge" : ""}`);
183
+ await response.body?.cancel?.().catch(() => { });
184
+ // A bot-challenge or any non-transient status won't clear on an
185
+ // immediate retry of the same URL — move to the next rendition (and,
186
+ // ultimately, the next re-lookup round).
187
+ if (challenged || !RETRYABLE_DOWNLOAD_STATUS.has(response.status))
188
+ break;
189
+ }
190
+ catch (error) {
191
+ failures.push(`${label}:${error instanceof Error ? error.message : String(error)}`);
192
+ }
147
193
  }
148
194
  }
149
195
  }
150
- throw new Error(`Source video download failed; all ${ranked.length} rendition(s) unavailable (${failures.join("; ")}).`);
196
+ throw new Error(`Source video download failed; all ${lastRankedCount} rendition(s) unavailable (${failures.join("; ")}).`);
151
197
  }
152
198
  //# sourceMappingURL=media-select.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@officexapp/vidfarm-devcli",
3
- "version": "0.21.14",
3
+ "version": "0.21.16",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -367,7 +367,10 @@
367
367
  /* chat column */
368
368
  .rk-aichat-main{flex:1;min-width:0;display:flex;flex-direction:column}
369
369
  .rk-aichat-head{display:flex;align-items:center;gap:8px;padding:14px 14px;border-bottom:1px solid var(--rk-border);flex:none}
370
- .rk-aichat-head-main{flex:1;min-width:0;display:grid;gap:1px}
370
+ .rk-aichat-head-main{flex:1;min-width:0;display:flex;align-items:center;gap:10px}
371
+ .rk-aichat-head-titles{display:grid;gap:1px;min-width:0}
372
+ .rk-aichat-watch{display:inline-flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--rk-text-muted);font-size:12px;font-weight:400;font-family:inherit;cursor:pointer;padding:3px 8px;border-radius:8px;white-space:nowrap;transition:color .15s,background .15s}
373
+ .rk-aichat-watch:hover{color:var(--rk-ink);background:var(--rk-n-100)}
371
374
  .rk-aichat-title{font-family:var(--rk-font-display);font-weight:800;font-size:15px;color:var(--rk-ink);letter-spacing:-.01em}
372
375
  .rk-aichat-sub{font-size:11.5px;color:var(--rk-text-muted)}
373
376
  .rk-aichat-back{flex:1;min-width:0;display:inline-flex;align-items:center;gap:8px;padding:7px 12px 7px 9px;
@@ -1125,6 +1128,17 @@ html,body{margin:0;background:#050604;color:#fffbe6}
1125
1128
  .rk-vf-btn:hover:not(:disabled){background:#262b1a;border-color:var(--rk-border-strong)}
1126
1129
  .rk-vf-btn:disabled{opacity:.45;cursor:default}
1127
1130
  .rk-vf-status{font-size:11.5px;color:var(--rk-text-muted);margin:0}
1131
+ .rk-vf-renders{display:grid;gap:5px;margin-top:2px}
1132
+ .rk-vf-renders-head{display:flex;align-items:center;justify-content:space-between}
1133
+ .rk-vf-renders-refresh{background:none;border:none;color:var(--rk-text-muted);cursor:pointer;
1134
+ font-size:13px;line-height:1;padding:2px 4px;border-radius:6px}
1135
+ .rk-vf-renders-refresh:hover{color:var(--rk-ink);background:#262b1a}
1136
+ .rk-vf-render-row{display:flex;align-items:center;justify-content:space-between;gap:8px;
1137
+ padding:7px 10px;border-radius:var(--rk-r-lg);border:1px solid var(--rk-border);background:#171b10;
1138
+ color:var(--rk-ink);font-size:12px;text-decoration:none;
1139
+ transition:background var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
1140
+ .rk-vf-render-row:hover{background:#222717;border-color:var(--rk-border-strong)}
1141
+ .rk-vf-render-label{font-weight:600}
1128
1142
 
1129
1143
  /* ── Rebrand: the sealed upstream <StudioApp/> header renders a "HeyGen ·
1130
1144
  HyperFrames" logo SVG (aria-label="Hyperframes"). The actual swap to a VidFarm
@@ -1189,10 +1203,6 @@ html,body{margin:0;background:#050604;color:#fffbe6}
1189
1203
  </div>
1190
1204
  <div class="rk-aichat-main">
1191
1205
  <header class="rk-aichat-head">
1192
- <a class="rk-aichat-back" id="rkAichatBack" href="/library" title="Back to your library">
1193
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
1194
- <span>Back to Library</span>
1195
- </a>
1196
1206
  <div class="rk-aichat-skill"><div class="vf-skill-install vf-skill-install-nav" data-skill-install>
1197
1207
  <button
1198
1208
  class="vf-skill-install-main"