@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/.agents/skills/vidfarm-director/SKILL.md +19 -4
- package/.agents/skills/vidfarm-director/references/assets-and-sourcing.md +6 -0
- package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +19 -4
- package/.agents/skills/vidfarm-director/references/core-workflows.md +2 -2
- package/.agents/skills/vidfarm-director/references/editor-workflows.md +29 -2
- package/SKILL.director.md +75 -12
- package/demo/dist/app.js +207 -207
- package/dist/src/cli.js +217 -10
- package/dist/src/devcli/cost-mode.js +22 -7
- package/dist/src/hyperframes/composition.js +2 -2
- package/dist/src/services/clip-curation/hunt.js +5 -4
- package/dist/src/services/clip-curation/media-select.js +76 -30
- package/package.json +1 -1
- package/public/serve-shells/editor.html +15 -5
- package/public/serve-shells/library-files.html +87 -3
- package/public/serve-shells/library-raws.html +222 -16
- package/public/serve-shells/tools-clipper.html +73 -4
- package/public/serve-shells/tools-image.html +172 -5
- package/public/serve-shells/tools-video.html +102 -4
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 {
|
|
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 |
|
|
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|
|
|
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/
|
|
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|
|
|
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|
|
|
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: ${
|
|
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|
|
|
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": "
|
|
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
|
-
" •
|
|
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 /
|
|
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
|
|
24
|
-
*
|
|
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
|
-
|
|
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)
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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(
|
|
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 ${
|
|
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.
|
|
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:
|
|
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"
|