@mevdragon/vidfarm-devcli 0.9.0 → 0.10.0
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/README.md +19 -1
- package/SKILL.director.md +57 -0
- package/SKILL.platform.md +13 -1
- package/dist/src/account-pages-legacy.js +15 -10
- package/dist/src/app.js +531 -183
- package/dist/src/cli.js +10 -4
- package/dist/src/config.js +5 -0
- package/dist/src/devcli/clips.js +312 -49
- package/dist/src/editor-chat.js +1 -1
- package/dist/src/frontend/homepage-view.js +0 -5
- package/dist/src/page-shell.js +47 -0
- package/dist/src/primitive-registry.js +7 -0
- package/dist/src/services/billing.js +3 -0
- package/dist/src/services/clip-curation/ffmpeg.js +59 -17
- package/dist/src/services/clip-curation/gemini.js +72 -23
- package/dist/src/services/clip-curation/hunt.js +332 -0
- package/dist/src/services/clip-curation/index.js +3 -1
- package/dist/src/services/clip-curation/local-agent.js +247 -0
- package/dist/src/services/clip-curation/refine.js +7 -29
- package/dist/src/services/clip-curation/scan.js +37 -10
- package/dist/src/services/serverless-records.js +9 -2
- package/dist/src/services/storage.js +16 -1
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +17 -17
package/dist/src/cli.js
CHANGED
|
@@ -144,10 +144,16 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
144
144
|
original vs decomposed (caption-free),
|
|
145
145
|
non-billing (alias: ghostcut)
|
|
146
146
|
|
|
147
|
-
Clip
|
|
148
|
-
clips scan <video-path>
|
|
149
|
-
→ embed → persist
|
|
150
|
-
|
|
147
|
+
Clip hunting (the third library — mine long-form video into a reusable clip store):
|
|
148
|
+
clips scan <video-path> Hunt short clips out of a long video. LOCAL-FIRST: local ffmpeg +
|
|
149
|
+
Detect → tag/transcribe → embed → persist your local claude/codex CLI
|
|
150
|
+
to ~/.vidfarm/clips.db. (keys are fallback)
|
|
151
|
+
--range "MM:SS-MM:SS" Only hunt these source windows (repeatable; big cost saver)
|
|
152
|
+
--duration <sec> Target clip length as a SOFT band (10→5-20s, 30→20-40s)
|
|
153
|
+
--aspect 9:16|16:9|4:3|1:1 Crop clips (also: vertical/horizontal/square) [--crop-focus <f>]
|
|
154
|
+
--no-text Prefer scenes WITHOUT captions/on-screen text (selection only)
|
|
155
|
+
--cloud [--url <url>] BACKUP: run on the deployed pipeline → POST /clips/scan (+ poll)
|
|
156
|
+
(uploads to your temp folder — 30-day TTL — bills AWS compute only)
|
|
151
157
|
--dry-run Show the estimated cost and stop (over $1 needs --yes/confirm)
|
|
152
158
|
clips list List the local clip library → GET /clips
|
|
153
159
|
clips search "<text>" Hybrid structured + semantic search → POST /clips/search
|
package/dist/src/config.js
CHANGED
|
@@ -68,6 +68,11 @@ const schema = z.object({
|
|
|
68
68
|
GHOSTCUT_KEY: z.string().optional(),
|
|
69
69
|
GHOSTCUT_SECRET: z.string().optional(),
|
|
70
70
|
GHOSTCUT_USD_PER_30S: z.coerce.number().min(0).default(0.10),
|
|
71
|
+
// GhostCut caption removal is for SHORT clips, never long-form sources —
|
|
72
|
+
// clip hunting must select text-free scenes instead of laundering a whole
|
|
73
|
+
// long video through GhostCut. 15 min default ceiling (per-30s pricing makes
|
|
74
|
+
// long-form runs absurd anyway).
|
|
75
|
+
GHOSTCUT_MAX_DURATION_SEC: z.coerce.number().min(0).default(900),
|
|
71
76
|
// Cloud passthrough for `vidfarm serve`: the local box keeps editing/render
|
|
72
77
|
// fully local but lists the upstream (prod) catalog in /discover and
|
|
73
78
|
// /library, seeds forks on demand, and can hand a render off to the cloud.
|
package/dist/src/devcli/clips.js
CHANGED
|
@@ -1,35 +1,55 @@
|
|
|
1
|
-
// devcli `vidfarm clips …` — the local clip-curation surface.
|
|
2
|
-
// ffmpeg
|
|
3
|
-
// (
|
|
4
|
-
//
|
|
5
|
-
|
|
1
|
+
// devcli `vidfarm clips …` — the local clip-curation surface. LOCAL-FIRST:
|
|
2
|
+
// local ffmpeg for compute and, by default, the user's local agent CLI
|
|
3
|
+
// subscription (claude/codex) for scene evaluation — no API key needed. BYOK
|
|
4
|
+
// provider keys are the first fallback, and `--cloud` is the explicit backup
|
|
5
|
+
// that runs the hunt on the deployed pipeline instead (never the default).
|
|
6
|
+
// Persists to a SQLite clip library (~/.vidfarm). Every keyed scan shows an
|
|
7
|
+
// estimated cost first; scans over $1 require --yes or an interactive confirm.
|
|
8
|
+
// Mirrors the cloud REST surface 1:1.
|
|
9
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
6
10
|
import path from "node:path";
|
|
7
11
|
import { homedir } from "node:os";
|
|
8
12
|
import { parseArgs } from "node:util";
|
|
9
13
|
import { createInterface } from "node:readline/promises";
|
|
10
14
|
import { createIdV7 } from "../lib/ids.js";
|
|
11
|
-
import { ClipModelClient, estimateScanCostFromScenes,
|
|
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";
|
|
12
16
|
import { ClipStore } from "./clip-store.js";
|
|
13
|
-
export const CLIPS_HELP = `vidfarm clips — build & search a local clip library (local ffmpeg +
|
|
17
|
+
export const CLIPS_HELP = `vidfarm clips — build & search a local clip library (local ffmpeg + local agent by default)
|
|
14
18
|
|
|
15
19
|
clips scan <video-path> Mine a long-form video into tagged, searchable clips
|
|
16
|
-
--provider <p> gemini | openai | openrouter
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
--
|
|
20
|
+
--provider <p> agent | gemini | openai | openrouter
|
|
21
|
+
Default: agent when a local claude/codex CLI is installed
|
|
22
|
+
(your subscription, no API key), else gemini.
|
|
23
|
+
--agent claude|codex Which local agent CLI to use for --provider agent (default: auto)
|
|
24
|
+
--tier flash|flash-lite Gemini tier (default: flash-lite; ignored for openai/openrouter/agent)
|
|
20
25
|
--prompt "<guidance>" Reshape clipping toward your goal: an LLM pass
|
|
21
|
-
keeps/drops/merges scenes, and tags are biased to it
|
|
26
|
+
keeps/drops/merges scenes, and tags are biased to it.
|
|
27
|
+
Inline hints are honored too: "between 12:30 and 15:45",
|
|
28
|
+
"30 sec clips", "vertical", "no captions".
|
|
29
|
+
--range "MM:SS-MM:SS" Only hunt inside this source range (repeatable / comma-separated)
|
|
30
|
+
--duration <sec> Target clip length as a SOFT band (10→5-20s, 30→20-40s, …)
|
|
31
|
+
--aspect <a> Crop clips to 9:16 | 16:9 | 4:3 | 1:1 (or vertical/horizontal/square)
|
|
32
|
+
--crop-focus <f> center | top | bottom | left | right (default: center)
|
|
33
|
+
--no-text Prefer scenes WITHOUT burned-in captions/on-screen text
|
|
34
|
+
(scene selection — NEVER GhostCut on the long-form source)
|
|
22
35
|
--no-refine Keep prompt tag-bias but skip the keep/drop/merge pass
|
|
36
|
+
--cloud BACKUP: run the hunt on the deployed pipeline instead of this
|
|
37
|
+
machine (uploads to your temp folder — auto-deletes in 30 days —
|
|
38
|
+
or pass --url; needs VIDFARM_API_KEY; bills AWS compute only)
|
|
39
|
+
--url <video-url> With --cloud: hunt a YouTube/TikTok/IG/X URL without uploading
|
|
40
|
+
--tracer <id> With --cloud: tag the hunt's billing/observability rollup
|
|
41
|
+
--api-url <base> With --cloud: deployment base URL (or VIDFARM_API_URL)
|
|
42
|
+
--api-key <key> With --cloud: API key (or VIDFARM_API_KEY)
|
|
23
43
|
--dry-run Print the estimated cost and exit
|
|
24
44
|
--yes Skip the >$1 confirmation prompt
|
|
25
45
|
--frames <n> Keyframes per scene for tagging (1-3, default 2)
|
|
26
46
|
--no-audio Skip per-scene audio (Gemini only; no transcripts; cheaper)
|
|
27
|
-
--concurrency <n> Parallel scenes (default 3;
|
|
47
|
+
--concurrency <n> Parallel scenes (default 3; agent default 2)
|
|
28
48
|
--threshold <0..1> Scene-cut sensitivity (default 0.3; lower = more cuts)
|
|
29
49
|
--min-scene <sec> Merge scenes shorter than this (default 0.6)
|
|
30
|
-
--max-scene <sec> Split scenes longer than this (default 8)
|
|
50
|
+
--max-scene <sec> Split scenes longer than this (default 8; --duration overrides)
|
|
31
51
|
--gemini-key / --openai-key / --openrouter-key <key> Provider key (or *_API_KEY env)
|
|
32
|
-
--embed-provider gemini|openai --embed-key <key> Embeddings
|
|
52
|
+
--embed-provider gemini|openai --embed-key <key> Embeddings for agent/openrouter scans
|
|
33
53
|
--home <dir> Library dir (default: ~/.vidfarm or VIDFARM_HOME)
|
|
34
54
|
|
|
35
55
|
clips list List clips in the library
|
|
@@ -97,9 +117,20 @@ async function runScan(argv) {
|
|
|
97
117
|
allowPositionals: true,
|
|
98
118
|
options: {
|
|
99
119
|
provider: { type: "string" },
|
|
120
|
+
agent: { type: "string" },
|
|
100
121
|
tier: { type: "string" },
|
|
101
122
|
prompt: { type: "string" },
|
|
123
|
+
range: { type: "string", multiple: true },
|
|
124
|
+
duration: { type: "string" },
|
|
125
|
+
aspect: { type: "string" },
|
|
126
|
+
"crop-focus": { type: "string" },
|
|
127
|
+
"no-text": { type: "boolean" },
|
|
102
128
|
"no-refine": { type: "boolean" },
|
|
129
|
+
cloud: { type: "boolean" },
|
|
130
|
+
url: { type: "string" },
|
|
131
|
+
tracer: { type: "string" },
|
|
132
|
+
"api-url": { type: "string" },
|
|
133
|
+
"api-key": { type: "string" },
|
|
103
134
|
"dry-run": { type: "boolean" },
|
|
104
135
|
yes: { type: "boolean" },
|
|
105
136
|
frames: { type: "string" },
|
|
@@ -116,67 +147,146 @@ async function runScan(argv) {
|
|
|
116
147
|
home: { type: "string" }
|
|
117
148
|
}
|
|
118
149
|
});
|
|
150
|
+
// ── Hunt spec: explicit flags win; inline prompt hints fill the gaps ──────
|
|
151
|
+
const guidancePromptRaw = values.prompt?.trim() || undefined;
|
|
152
|
+
const parsedPrompt = parseClipHuntPrompt(guidancePromptRaw);
|
|
153
|
+
const rawWindows = values.range?.length
|
|
154
|
+
? parseTimeRanges(values.range)
|
|
155
|
+
: parsedPrompt.windows;
|
|
156
|
+
const durationBand = values.duration
|
|
157
|
+
? resolveDurationBand(Number(values.duration))
|
|
158
|
+
: parsedPrompt.target_duration_sec != null
|
|
159
|
+
? resolveDurationBand(parsedPrompt.target_duration_sec)
|
|
160
|
+
: null;
|
|
161
|
+
const aspect = values.aspect
|
|
162
|
+
? (normalizeAspect(values.aspect) ?? (() => { throw new Error(`--aspect must be 9:16 | 16:9 | 4:3 | 1:1 (or vertical/horizontal/square), got "${values.aspect}"`); })())
|
|
163
|
+
: parsedPrompt.aspect;
|
|
164
|
+
const cropFocus = normalizeCropFocus(values["crop-focus"]);
|
|
165
|
+
const avoidText = Boolean(values["no-text"]) || parsedPrompt.avoid_text;
|
|
166
|
+
const guidance = buildEffectiveGuidance({ contentPrompt: guidancePromptRaw, avoidText }) || undefined;
|
|
167
|
+
// ── BACKUP path: run the hunt on the deployed pipeline (--cloud) ──────────
|
|
168
|
+
if (values.cloud) {
|
|
169
|
+
return runScanCloud({
|
|
170
|
+
videoArg: positionals[0],
|
|
171
|
+
sourceUrl: values.url?.trim() || undefined,
|
|
172
|
+
apiUrl: values["api-url"],
|
|
173
|
+
apiKey: values["api-key"],
|
|
174
|
+
tracer: values.tracer,
|
|
175
|
+
prompt: guidancePromptRaw,
|
|
176
|
+
windows: rawWindows,
|
|
177
|
+
durationBand,
|
|
178
|
+
aspect,
|
|
179
|
+
cropFocus,
|
|
180
|
+
avoidText
|
|
181
|
+
});
|
|
182
|
+
}
|
|
119
183
|
const videoArg = positionals[0];
|
|
120
184
|
if (!videoArg)
|
|
121
|
-
throw new Error("Usage: vidfarm clips scan <video-path> [--
|
|
185
|
+
throw new Error("Usage: vidfarm clips scan <video-path> [--range MM:SS-MM:SS] [--duration 30] [--aspect 9:16] [--dry-run]");
|
|
122
186
|
const videoPath = path.resolve(process.cwd(), videoArg);
|
|
123
187
|
if (!existsSync(videoPath))
|
|
124
188
|
throw new Error(`No such video: ${videoPath}`);
|
|
125
189
|
if (!(await hasFfmpeg())) {
|
|
126
190
|
throw new Error("ffmpeg is required for `clips scan` but was not found (bundled ffmpeg-static missing and none on PATH).");
|
|
127
191
|
}
|
|
128
|
-
|
|
192
|
+
// ── Model resolution: LOCAL-FIRST ─────────────────────────────────────────
|
|
193
|
+
// 1. --provider agent (or no --provider + a local claude/codex CLI installed)
|
|
194
|
+
// → the user's local agent subscription evaluates scenes; no API key.
|
|
195
|
+
// 2. --provider gemini|openai|openrouter (or a key in env/config) → BYOK API.
|
|
196
|
+
// 3. --cloud (handled above) → the deployed pipeline, as an explicit backup.
|
|
197
|
+
const provider = resolveScanProvider(values);
|
|
129
198
|
const tier = normalizeTier(values.tier);
|
|
130
199
|
const framesPerScene = clampInt(values.frames, 2, 1, 3);
|
|
131
|
-
// Audio is Gemini-only (chat models on openai/openrouter don't take the clip).
|
|
200
|
+
// Audio is Gemini-only (chat models on openai/openrouter — and CLI agents — don't take the clip).
|
|
132
201
|
const includeAudio = !values["no-audio"] && provider === "gemini";
|
|
133
|
-
|
|
134
|
-
const concurrency = clampInt(values.concurrency, 3, 1, 12);
|
|
202
|
+
// Local agents run one process per call — keep parallelism modest by default.
|
|
203
|
+
const concurrency = clampInt(values.concurrency, provider === "agent" ? 2 : 3, 1, 12);
|
|
135
204
|
const sceneOptions = {
|
|
136
205
|
threshold: values.threshold ? Number(values.threshold) : undefined,
|
|
137
206
|
minSceneSec: values["min-scene"] ? Number(values["min-scene"]) : undefined,
|
|
138
|
-
|
|
207
|
+
// A duration band lifts the split ceiling; an explicit --max-scene still wins.
|
|
208
|
+
maxSceneSec: values["max-scene"] ? Number(values["max-scene"]) : durationBand?.max_sec
|
|
139
209
|
};
|
|
140
210
|
// Resolve the tagging key + optional embedding config. Optional here so
|
|
141
|
-
// --dry-run can show the cost without a key;
|
|
142
|
-
const apiKey = resolveProviderKey(provider, values, { optional: true });
|
|
143
|
-
const embedding =
|
|
211
|
+
// --dry-run can show the cost without a key; required below to scan (API providers).
|
|
212
|
+
const apiKey = provider === "agent" ? "" : resolveProviderKey(provider, values, { optional: true });
|
|
213
|
+
const embedding = provider === "agent"
|
|
214
|
+
? resolveAgentEmbeddingConfig(values)
|
|
215
|
+
: apiKey ? resolveEmbeddingConfig(provider, apiKey, values) : null;
|
|
144
216
|
console.log(`[clips] probing ${path.basename(videoPath)} …`);
|
|
145
217
|
const probe = await probeVideo(videoPath);
|
|
146
218
|
console.log(`[clips] ${fmtDuration(probe.duration_sec)} · ${probe.width ?? "?"}x${probe.height ?? "?"} · ${probe.codec ?? "?"}`);
|
|
219
|
+
const windows = normalizeWindows(rawWindows, probe.duration_sec);
|
|
220
|
+
if (rawWindows.length && !windows.length) {
|
|
221
|
+
throw new Error("--range produced no usable window inside the video's duration.");
|
|
222
|
+
}
|
|
223
|
+
if (windows.length) {
|
|
224
|
+
console.log(`[clips] 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)}).`);
|
|
225
|
+
}
|
|
226
|
+
if (durationBand) {
|
|
227
|
+
console.log(`[clips] target clip length ~${durationBand.target_sec}s (soft range ${durationBand.min_sec}–${durationBand.max_sec}s).`);
|
|
228
|
+
}
|
|
229
|
+
if (aspect && aspect !== "original")
|
|
230
|
+
console.log(`[clips] cropping clips to ${aspect} (${cropFocus}).`);
|
|
231
|
+
if (avoidText)
|
|
232
|
+
console.log(`[clips] preferring scenes without on-screen text/captions (selection only — GhostCut is never run on the source).`);
|
|
147
233
|
console.log(`[clips] detecting scenes …`);
|
|
148
|
-
const
|
|
234
|
+
const detected = await detectScenes(videoPath, { durationSec: probe.duration_sec, windows, ...sceneOptions });
|
|
235
|
+
const scenes = fitScenesToDurationBand(detected, durationBand);
|
|
149
236
|
if (scenes.length === 0)
|
|
150
237
|
throw new Error("No scenes detected (is the video readable / non-empty?).");
|
|
151
238
|
const store = new ClipStore(values.home);
|
|
152
|
-
|
|
239
|
+
let client = null;
|
|
240
|
+
if (provider === "agent") {
|
|
241
|
+
client = new LocalAgentClipClient({
|
|
242
|
+
agent: values.agent === "claude" || values.agent === "codex" ? values.agent : "auto",
|
|
243
|
+
embedding,
|
|
244
|
+
tier
|
|
245
|
+
});
|
|
246
|
+
console.log(`[clips] evaluating scenes with your local ${client.tagModelId} subscription (no API cost).`);
|
|
247
|
+
}
|
|
248
|
+
else if (apiKey) {
|
|
249
|
+
client = new ClipModelClient({ provider, apiKey, tier, embedding });
|
|
250
|
+
}
|
|
153
251
|
if (client && !client.canEmbed) {
|
|
154
252
|
console.warn(`[clips] no embedding provider for "${provider}" — clips will be structured-searchable only.`);
|
|
155
253
|
console.warn(` Add --embed-provider gemini|openai --embed-key <key> for semantic search.`);
|
|
156
254
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
255
|
+
// Cost estimate: local-agent scans ride the user's subscription, so only the
|
|
256
|
+
// optional embedding spend applies; API scans estimate the full BYOK cost.
|
|
257
|
+
const estimate = provider === "agent"
|
|
258
|
+
? null
|
|
259
|
+
: estimateScanCostFromScenes(scenes, {
|
|
260
|
+
tier,
|
|
261
|
+
provider,
|
|
262
|
+
tagModelId: client?.tagModelId,
|
|
263
|
+
embedModelId: client ? client.embeddingModelId : defaultEmbedModelForEstimate(provider),
|
|
264
|
+
framesPerScene,
|
|
265
|
+
includeAudio
|
|
266
|
+
});
|
|
267
|
+
if (estimate) {
|
|
268
|
+
console.log(`\n Cost estimate: ${formatCostEstimate(estimate)}`);
|
|
269
|
+
console.log(` Target: $0.05–0.10 per source hour (${withinTarget(estimate.usd_per_source_hour) ? "on target" : "note"}).`);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
console.log(`\n Cost: local agent subscription (${scenes.length} scenes; embeddings ${embedding ? `via ${embedding.provider}` : "off"}).`);
|
|
273
|
+
}
|
|
274
|
+
if (guidance)
|
|
275
|
+
console.log(` Guidance: "${guidance.replace(/\n/g, " · ")}"`);
|
|
169
276
|
console.log("");
|
|
170
277
|
if (values["dry-run"]) {
|
|
171
278
|
console.log("[clips] --dry-run: not scanning. Re-run without --dry-run to build the library.");
|
|
172
279
|
return;
|
|
173
280
|
}
|
|
174
|
-
// Actually scanning now —
|
|
175
|
-
if (!client)
|
|
176
|
-
|
|
281
|
+
// Actually scanning now — an evaluator is required.
|
|
282
|
+
if (!client) {
|
|
283
|
+
if (provider !== "agent")
|
|
284
|
+
resolveProviderKey(provider, values); // throws the "No <provider> API key" error
|
|
285
|
+
throw new Error("No scene evaluator available. Install Claude Code (`claude`) or Codex (`codex`), pass a provider key, or run with --cloud.");
|
|
286
|
+
}
|
|
177
287
|
const activeClient = client;
|
|
178
|
-
// $1 confirmation gate (handoff Target 1).
|
|
179
|
-
if (estimate.usd > 1 && !values.yes) {
|
|
288
|
+
// $1 confirmation gate (handoff Target 1) — keyed scans only.
|
|
289
|
+
if (estimate && estimate.usd > 1 && !values.yes) {
|
|
180
290
|
const ok = await confirm(`This scan is estimated at $${estimate.usd.toFixed(2)}. Proceed? [y/N] `);
|
|
181
291
|
if (!ok) {
|
|
182
292
|
console.log("[clips] aborted.");
|
|
@@ -200,9 +310,10 @@ async function runScan(argv) {
|
|
|
200
310
|
thumbsDir: store.paths.thumbsDir,
|
|
201
311
|
framesPerScene,
|
|
202
312
|
includeAudio,
|
|
203
|
-
guidancePrompt,
|
|
313
|
+
guidancePrompt: guidance,
|
|
204
314
|
refineSegmentation: !values["no-refine"],
|
|
205
|
-
reencodeClips: true
|
|
315
|
+
reencodeClips: true,
|
|
316
|
+
crop: aspect && aspect !== "original" ? { aspect, focus: cropFocus } : undefined
|
|
206
317
|
};
|
|
207
318
|
console.log(`[clips] scanning ${scenes.length} scenes (${provider}/${activeClient.tagModelId}, ${concurrency}x) …`);
|
|
208
319
|
let errors = 0;
|
|
@@ -212,6 +323,8 @@ async function runScan(argv) {
|
|
|
212
323
|
videoPath,
|
|
213
324
|
scenes,
|
|
214
325
|
probe,
|
|
326
|
+
windows,
|
|
327
|
+
durationBand,
|
|
215
328
|
sourceFilename: path.basename(videoPath),
|
|
216
329
|
concurrency,
|
|
217
330
|
onRefine: (r) => {
|
|
@@ -253,6 +366,139 @@ async function runScan(argv) {
|
|
|
253
366
|
console.log(` db → ${store.paths.db}`);
|
|
254
367
|
console.log(` try: vidfarm clips search "…" | vidfarm clips preset run "funny reaction gifs"`);
|
|
255
368
|
}
|
|
369
|
+
// ── clips scan --cloud (explicit backup: the deployed pipeline) ─────────────
|
|
370
|
+
// Uploads the local file into the user's temp folder (30-day TTL, auto-removed)
|
|
371
|
+
// — or passes --url straight through — then starts POST /clips/scan and polls.
|
|
372
|
+
// The hunt bills AWS compute only; AI runs on the account's saved BYOK keys.
|
|
373
|
+
async function runScanCloud(input) {
|
|
374
|
+
const baseUrl = (input.apiUrl || process.env.VIDFARM_API_URL || process.env.VIDFARM_UPSTREAM_HOST || "https://vidfarm.cc").replace(/\/$/, "");
|
|
375
|
+
const apiKey = input.apiKey || process.env.VIDFARM_API_KEY || "";
|
|
376
|
+
if (!apiKey)
|
|
377
|
+
throw new Error("--cloud needs an API key: pass --api-key or set VIDFARM_API_KEY.");
|
|
378
|
+
const headers = { "vidfarm-api-key": apiKey, "content-type": "application/json", accept: "application/json" };
|
|
379
|
+
let tempFileId;
|
|
380
|
+
let fileName;
|
|
381
|
+
if (!input.sourceUrl) {
|
|
382
|
+
if (!input.videoArg)
|
|
383
|
+
throw new Error("Usage: vidfarm clips scan --cloud <video-path> | --cloud --url <video-url>");
|
|
384
|
+
const videoPath = path.resolve(process.cwd(), input.videoArg);
|
|
385
|
+
if (!existsSync(videoPath))
|
|
386
|
+
throw new Error(`No such video: ${videoPath}`);
|
|
387
|
+
fileName = path.basename(videoPath);
|
|
388
|
+
const sizeBytes = statSync(videoPath).size;
|
|
389
|
+
console.log(`[clips] uploading ${fileName} (${(sizeBytes / (1024 * 1024)).toFixed(1)} MB) to your temp folder (auto-deletes in 30 days) …`);
|
|
390
|
+
const presignRes = await fetch(`${baseUrl}/api/v1/user/me/temporary-files/presign`, {
|
|
391
|
+
method: "POST",
|
|
392
|
+
headers,
|
|
393
|
+
body: JSON.stringify({ file_name: fileName, content_type: "video/mp4", size_bytes: sizeBytes, folder_path: "clip-sources" })
|
|
394
|
+
});
|
|
395
|
+
const presign = await presignRes.json();
|
|
396
|
+
if (!presignRes.ok || !presign.upload || !presign.file_id) {
|
|
397
|
+
throw new Error(`Temp upload presign failed (${presignRes.status}): ${presign.error ?? "unexpected response"}`);
|
|
398
|
+
}
|
|
399
|
+
if (presign.transport !== "presigned") {
|
|
400
|
+
throw new Error("The deployment does not support presigned uploads — upload via the web UI, then re-run with --url or temp_file_id.");
|
|
401
|
+
}
|
|
402
|
+
const putRes = await fetch(presign.upload.url, {
|
|
403
|
+
method: presign.upload.method,
|
|
404
|
+
headers: presign.upload.headers,
|
|
405
|
+
body: readFileSync(path.resolve(process.cwd(), input.videoArg))
|
|
406
|
+
});
|
|
407
|
+
if (!putRes.ok)
|
|
408
|
+
throw new Error(`Upload failed (${putRes.status}).`);
|
|
409
|
+
const finalizeRes = await fetch(`${baseUrl}/api/v1/user/me/temporary-files`, {
|
|
410
|
+
method: "POST",
|
|
411
|
+
headers,
|
|
412
|
+
body: JSON.stringify({
|
|
413
|
+
file_id: presign.file_id,
|
|
414
|
+
file_name: fileName,
|
|
415
|
+
content_type: "video/mp4",
|
|
416
|
+
size_bytes: sizeBytes,
|
|
417
|
+
storage_key: presign.storage_key,
|
|
418
|
+
folder_path: "clip-sources"
|
|
419
|
+
})
|
|
420
|
+
});
|
|
421
|
+
if (!finalizeRes.ok) {
|
|
422
|
+
const body = await finalizeRes.text().catch(() => "");
|
|
423
|
+
throw new Error(`Temp upload finalize failed (${finalizeRes.status}): ${body.slice(0, 300)}`);
|
|
424
|
+
}
|
|
425
|
+
tempFileId = presign.file_id;
|
|
426
|
+
}
|
|
427
|
+
console.log(`[clips] starting cloud hunt …`);
|
|
428
|
+
const scanRes = await fetch(`${baseUrl}/clips/scan`, {
|
|
429
|
+
method: "POST",
|
|
430
|
+
headers,
|
|
431
|
+
body: JSON.stringify({
|
|
432
|
+
...(input.sourceUrl ? { source_url: input.sourceUrl } : { temp_file_id: tempFileId, filename: fileName }),
|
|
433
|
+
prompt: input.prompt ?? "",
|
|
434
|
+
...(input.tracer ? { tracer: input.tracer } : {}),
|
|
435
|
+
hunt_spec: {
|
|
436
|
+
...(input.windows.length ? { windows: input.windows } : {}),
|
|
437
|
+
...(input.durationBand ? { duration_band: input.durationBand } : {}),
|
|
438
|
+
...(input.aspect ? { aspect: input.aspect } : {}),
|
|
439
|
+
crop_focus: input.cropFocus,
|
|
440
|
+
...(input.avoidText ? { avoid_text: true } : {})
|
|
441
|
+
}
|
|
442
|
+
})
|
|
443
|
+
});
|
|
444
|
+
const scan = await scanRes.json();
|
|
445
|
+
if (!scanRes.ok || !scan.scan_id) {
|
|
446
|
+
throw new Error(`Cloud scan failed to start (${scanRes.status}): ${scan.error ?? "unexpected response"}`);
|
|
447
|
+
}
|
|
448
|
+
console.log(`[clips] scan ${scan.scan_id} ${scan.status ?? "queued"}${scan.tracer ? ` (tracer ${scan.tracer})` : ""}.`);
|
|
449
|
+
if (scan.compute_estimate?.estimated_charge_usd != null) {
|
|
450
|
+
console.log(`[clips] estimated AWS compute charge ~$${scan.compute_estimate.estimated_charge_usd.toFixed(4)} (AI runs on your saved provider keys).`);
|
|
451
|
+
}
|
|
452
|
+
// Async by design — poll until the pipeline finishes.
|
|
453
|
+
const started = timestamp();
|
|
454
|
+
for (;;) {
|
|
455
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
456
|
+
const pollRes = await fetch(`${baseUrl}/clips/scan/${encodeURIComponent(scan.scan_id)}`, { headers });
|
|
457
|
+
if (!pollRes.ok)
|
|
458
|
+
continue;
|
|
459
|
+
const poll = await pollRes.json();
|
|
460
|
+
const status = poll.source?.status ?? poll.scan?.status ?? "running";
|
|
461
|
+
if (status === "complete") {
|
|
462
|
+
console.log(`\n[clips] cloud hunt done in ${elapsed(started)}: ${poll.source?.clip_count ?? 0} clips.`);
|
|
463
|
+
console.log(` browse → ${baseUrl}/clips`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (status === "failed") {
|
|
467
|
+
throw new Error(`Cloud hunt failed: ${poll.scan?.error ?? "unknown error"}`);
|
|
468
|
+
}
|
|
469
|
+
process.stdout.write(`\r[clips] ${status} … ${elapsed(started)} `);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* LOCAL-FIRST provider pick: an explicit --provider always wins; otherwise use
|
|
474
|
+
* the local agent CLI when one is installed (subscription-powered, keyless),
|
|
475
|
+
* else fall back to gemini BYOK. The cloud pipeline is never a silent default
|
|
476
|
+
* — it's the explicit `--cloud` backup.
|
|
477
|
+
*/
|
|
478
|
+
function resolveScanProvider(values) {
|
|
479
|
+
if (values.provider === "agent" || values.provider === "local" || values.provider === "local-agent")
|
|
480
|
+
return "agent";
|
|
481
|
+
if (values.provider)
|
|
482
|
+
return normalizeProvider(values.provider);
|
|
483
|
+
if (values.agent)
|
|
484
|
+
return "agent";
|
|
485
|
+
if (detectLocalAgent())
|
|
486
|
+
return "agent";
|
|
487
|
+
return "gemini";
|
|
488
|
+
}
|
|
489
|
+
/** Embeddings for a local-agent scan: any gemini/openai key found makes the library semantically searchable. */
|
|
490
|
+
function resolveAgentEmbeddingConfig(flags) {
|
|
491
|
+
const explicit = flags["embed-provider"]?.trim();
|
|
492
|
+
const tryProvider = (p) => {
|
|
493
|
+
const key = flags["embed-key"]?.trim() || resolveProviderKey(p, flags, { optional: true });
|
|
494
|
+
return key ? { provider: p, apiKey: key } : null;
|
|
495
|
+
};
|
|
496
|
+
if (explicit === "gemini")
|
|
497
|
+
return tryProvider("gemini");
|
|
498
|
+
if (explicit === "openai")
|
|
499
|
+
return tryProvider("openai");
|
|
500
|
+
return tryProvider("gemini") ?? tryProvider("openai");
|
|
501
|
+
}
|
|
256
502
|
// ── clips list ──────────────────────────────────────────────────────────────
|
|
257
503
|
async function runList(argv) {
|
|
258
504
|
const { values } = parseArgs({
|
|
@@ -320,8 +566,15 @@ async function runSearch(argv) {
|
|
|
320
566
|
queryEmbedding = await client.embedQuery(criteria.semantic_text ?? query);
|
|
321
567
|
}
|
|
322
568
|
}
|
|
569
|
+
else if (detectLocalAgent()) {
|
|
570
|
+
// Local-first: no key, but a local agent CLI can still turn the natural-
|
|
571
|
+
// language query into a structured filter (vector re-rank needs a key).
|
|
572
|
+
const agent = new LocalAgentClipClient({});
|
|
573
|
+
console.log(`[clips] structuring query with your local ${agent.tagModelId} …`);
|
|
574
|
+
criteria = await agent.naturalLanguageToCriteria(query);
|
|
575
|
+
}
|
|
323
576
|
else {
|
|
324
|
-
console.warn(`[clips] no ${provider} key — structured/keyword search only
|
|
577
|
+
console.warn(`[clips] no ${provider} key or local agent CLI — structured/keyword search only.`);
|
|
325
578
|
}
|
|
326
579
|
const t0 = timestamp();
|
|
327
580
|
const hits = store.search({ criteria, queryEmbedding, limit });
|
|
@@ -550,10 +803,20 @@ async function runPresetSave(argv) {
|
|
|
550
803
|
criteria = asJson;
|
|
551
804
|
}
|
|
552
805
|
else {
|
|
553
|
-
//
|
|
806
|
+
// Natural language → structured: provider key when present, else the
|
|
807
|
+
// local agent CLI (local-first), else the usual missing-key error.
|
|
554
808
|
const provider = normalizeProvider(values.provider);
|
|
555
|
-
const apiKey = resolveProviderKey(provider, values);
|
|
556
|
-
|
|
809
|
+
const apiKey = resolveProviderKey(provider, values, { optional: true });
|
|
810
|
+
if (apiKey) {
|
|
811
|
+
criteria = await new ClipModelClient({ provider, apiKey }).naturalLanguageToCriteria(fromQuery);
|
|
812
|
+
}
|
|
813
|
+
else if (detectLocalAgent()) {
|
|
814
|
+
criteria = await new LocalAgentClipClient({}).naturalLanguageToCriteria(fromQuery);
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
resolveProviderKey(provider, values); // throws the "No <provider> API key" error
|
|
818
|
+
throw new Error("unreachable");
|
|
819
|
+
}
|
|
557
820
|
}
|
|
558
821
|
const store = new ClipStore(values.home);
|
|
559
822
|
const preset = store.savePreset({ name, criteria, description: values.description });
|
package/dist/src/editor-chat.js
CHANGED
|
@@ -41,7 +41,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
41
41
|
"Use POST /api/v1/primitives/images/edit only when the user wants to revise the attached/source image itself; its body is { tracer, payload: { source_image_url, instruction, reference_attachments? } }. Use POST /api/v1/primitives/images/generate when they want a new image inspired by or similar to the attachment.",
|
|
42
42
|
"If the user asks to modify an attached image and the message includes an attachment URL, call POST /api/v1/primitives/images/edit with source_image_url inside payload set to that exact URL and instruction inside payload set to the requested edit.",
|
|
43
43
|
"If the user asks to generate a new AI video from text or reference images, call POST /api/v1/primitives/videos/generate with body { tracer, payload: { prompt, input_references?, duration? } }. AI video duration is seconds; use duration: 4 for a 4-second video and never use duration_ms on /videos/generate. input_references must be direct image asset URLs, not webpage or social post URLs. Use frame_images for exact first/last-frame image-to-video.",
|
|
44
|
-
"If the user asks to render HTML/design into an image, call POST /api/v1/primitives/images/render-html. If the user asks to turn existing media URLs into a slideshow/video, call POST /api/v1/primitives/videos/render-slides. If the user asks to dedupe, camouflage, or apply small zoom/tilt/rotate/filter/tint transforms to an image or video for reuse, call POST /api/v1/primitives/media/dedupe with body { tracer, payload: { source_media_url, media_type?, effects?: { zoom?, tilt?, rotate?, saturation?, speed?, horizontal_flip?, contrast?, brightness?, hue_rotate?, blur? }, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Primitive media utility routes are also allowed directly here, including /videos/trim, /videos/remove-captions, /videos/extract-audio, /videos/probe, /videos/extract-frame, /videos/mute, /videos/replace-audio, /videos/concat, /audio/trim, /audio/probe, /audio/concat, and /audio/normalize. If the user asks to remove burned-in captions, subtitles, or on-screen text from a video, call POST /api/v1/primitives/videos/remove-captions with body { tracer, payload: { source_video_url } } — an async job that returns a durable caption-free MP4 and bills about $0.10 per 30 seconds of source video.",
|
|
44
|
+
"If the user asks to render HTML/design into an image, call POST /api/v1/primitives/images/render-html. If the user asks to turn existing media URLs into a slideshow/video, call POST /api/v1/primitives/videos/render-slides. If the user asks to dedupe, camouflage, or apply small zoom/tilt/rotate/filter/tint transforms to an image or video for reuse, call POST /api/v1/primitives/media/dedupe with body { tracer, payload: { source_media_url, media_type?, effects?: { zoom?, tilt?, rotate?, saturation?, speed?, horizontal_flip?, contrast?, brightness?, hue_rotate?, blur? }, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Primitive media utility routes are also allowed directly here, including /videos/trim, /videos/remove-captions, /videos/extract-audio, /videos/probe, /videos/extract-frame, /videos/mute, /videos/replace-audio, /videos/concat, /audio/trim, /audio/probe, /audio/concat, and /audio/normalize. If the user asks to remove burned-in captions, subtitles, or on-screen text from a video, call POST /api/v1/primitives/videos/remove-captions with body { tracer, payload: { source_video_url } } — an async job that returns a durable caption-free MP4 and bills about $0.10 per 30 seconds of source video. Caption removal is capped at ~15 minutes of source and must NEVER be used on long-form video: when the user wants short clips out of a long video (a podcast, stream VOD, webinar, or a YouTube/TikTok/IG/X URL), call POST /clips/scan instead with body { source_url | temp_file_id | s3_key, prompt, ranges?: [\"MM:SS-MM:SS\"], target_duration_sec?, aspect?: \"9:16\"|\"16:9\"|\"4:3\"|\"1:1\", avoid_text?, tracer? } — target_duration_sec is a SOFT range (30 → 20-40s), ranges restrict the hunt to those source windows (big cost saver), aspect crops every clip, and avoid_text prefers text-free scenes via scene SELECTION (never caption removal on the source). The hunt is async (202 + scan_id): poll GET /clips/scan/:scanId, then browse GET /clips/feed?source=<source_video_id> or POST /clips/search. Hunts bill AWS compute only — the AI tagging runs on the user's own saved provider keys. To erase captions from a finished HUNTED clip, pass that short mp4 to /videos/remove-captions afterwards.",
|
|
45
45
|
"Brainstorm primitives are also available directly here. If the user explicitly asks for hooks, angles, awareness-stage advice, a cold-start strategy questionnaire, or product-placement opportunities in a video, call the matching brainstorm primitive immediately instead of brainstorming in chat from memory. Use POST /api/v1/primitives/brainstorm/coldstart with body { tracer, payload: { user_message }, webhook_url? } when the user is clueless and needs foundational strategy questions. Use /brainstorm/awareness_stages with body { tracer, payload: { offer_description }, webhook_url? } when they are unsure what type of ads to make. Use /brainstorm/angles with body { tracer, payload: { offer_description, problem_awareness, solution_awareness }, webhook_url? } when they want persuasive ad angles; problem_awareness must be problem_unaware or problem_aware, and solution_awareness must be solution_unaware or solution_aware. Use /brainstorm/hooks with body { tracer, payload: { offer_description }, webhook_url? } when they are stuck on openings and want many hooks.",
|
|
46
46
|
"Product placement is a first-class brainstorm primitive, just like angles and hooks. When the user asks to identify product-placement opportunities in a video, or to repurpose a video into a native ad for their product, prefer POST /api/v1/primitives/brainstorm/product_placement with body { tracer, payload: { source_video_url, offer_description }, webhook_url? }. source_video_url is the exact video to analyze — for the current composition, first call GET /api/v1/compositions/:forkId/remove-video-captions to pick the correct source (the ORIGINAL uploaded video vs the DECOMPOSED, caption-free video), then pass that URL. offer_description is the user's product/offer. This primitive watches the video and returns timestamped, native placement opportunities. It prefers a Gemini key for video understanding.",
|
|
47
47
|
"BEFORE calling /brainstorm/product_placement for the CURRENT composition, first call the product_placement_context tool (or GET /api/v1/compositions/:forkId/product-placement.json). Decompose already scouted product-AGNOSTIC placement surfaces for this fork — real, grounded moments (timestamp, surface_type, surface_note, best_for, why_it_works). Use those surfaces to ground your answer and to seed the primitive: fold the strongest surfaces into offer_description (for example append 'Known native placement surfaces: 0:04 empty desk prop; 0:12 phone screen swap') so the product-specific pass builds on grounded moments instead of re-watching blind. If the user just wants a quick conversational take on where their product could go, the surfaces from product_placement_context are often enough to answer directly without the billable primitive.",
|
|
@@ -11,9 +11,6 @@ function LibraryIcon() {
|
|
|
11
11
|
function CalendarIcon() {
|
|
12
12
|
return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("circle", { cx: "10", cy: "10", r: "7.2" }), _jsx("path", { d: "M10 6.3v4.1l2.8 1.7" })] });
|
|
13
13
|
}
|
|
14
|
-
function ClipsIcon() {
|
|
15
|
-
return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { x: "3", y: "4.5", width: "14", height: "11", rx: "1.5" }), _jsx("path", { d: "M3 8h14" }), _jsx("path", { d: "M6.2 4.7 4.6 8" }), _jsx("path", { d: "M9.6 4.7 8 8" }), _jsx("path", { d: "M13 4.7 11.4 8" })] });
|
|
16
|
-
}
|
|
17
14
|
function ChatIcon() {
|
|
18
15
|
return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M4.2 5.2a2 2 0 0 1 2-2h7.6a2 2 0 0 1 2 2v5.6a2 2 0 0 1-2 2H9.2l-4.1 3.1v-3.1a2 2 0 0 1-.9-1.7Z" }), _jsx("path", { d: "M7.3 7.1h5.4" }), _jsx("path", { d: "M7.3 9.6h3.7" })] });
|
|
19
16
|
}
|
|
@@ -58,13 +55,11 @@ function PrimaryNav({ account }) {
|
|
|
58
55
|
? [
|
|
59
56
|
{ id: "discover", href: withAccountQuery("/discover", account.userId), label: "Discover", icon: _jsx(DiscoverIcon, {}) },
|
|
60
57
|
{ id: "library", href: withAccountQuery("/library", account.userId), label: "Library", icon: _jsx(LibraryIcon, {}) },
|
|
61
|
-
{ id: "clips", href: "/clips", label: "Clips", icon: _jsx(ClipsIcon, {}) },
|
|
62
58
|
{ id: "calendar", href: withAccountQuery("/calendar", account.userId), label: "Calendar", icon: _jsx(CalendarIcon, {}) },
|
|
63
59
|
{ id: "settings", href: withAccountQuery("/settings", account.userId), label: "Settings", icon: _jsx(SettingsIcon, {}) }
|
|
64
60
|
]
|
|
65
61
|
: [
|
|
66
62
|
{ id: "discover", href: "/discover", label: "Discover", icon: _jsx(DiscoverIcon, {}) },
|
|
67
|
-
{ id: "clips", href: "/clips", label: "Clips", icon: _jsx(ClipsIcon, {}) },
|
|
68
63
|
{ id: "login", href: "/login", label: "Login", icon: _jsx(SettingsIcon, {}) }
|
|
69
64
|
];
|
|
70
65
|
const menuItems = account.isLoggedIn
|
package/dist/src/page-shell.js
CHANGED
|
@@ -756,6 +756,53 @@ export function renderPageShell(input) {
|
|
|
756
756
|
color: var(--muted);
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
+
.view-toggle {
|
|
760
|
+
display: inline-flex;
|
|
761
|
+
flex: 0 0 auto;
|
|
762
|
+
gap: 4px;
|
|
763
|
+
padding: 3px;
|
|
764
|
+
border: 1px solid rgba(191, 164, 109, 0.28);
|
|
765
|
+
border-radius: 999px;
|
|
766
|
+
background: linear-gradient(180deg, rgba(247, 241, 230, 0.94), rgba(244, 236, 223, 0.96));
|
|
767
|
+
box-shadow:
|
|
768
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.88),
|
|
769
|
+
0 8px 18px rgba(138, 102, 32, 0.08);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
.view-toggle-tab {
|
|
773
|
+
display: inline-flex;
|
|
774
|
+
align-items: center;
|
|
775
|
+
justify-content: center;
|
|
776
|
+
min-height: 38px;
|
|
777
|
+
padding: 0 18px;
|
|
778
|
+
border: 1px solid transparent;
|
|
779
|
+
border-radius: 999px;
|
|
780
|
+
background: transparent;
|
|
781
|
+
color: #6f6451;
|
|
782
|
+
text-decoration: none;
|
|
783
|
+
font-size: 0.9rem;
|
|
784
|
+
font-weight: 700;
|
|
785
|
+
font-family: inherit;
|
|
786
|
+
white-space: nowrap;
|
|
787
|
+
cursor: pointer;
|
|
788
|
+
box-shadow: none;
|
|
789
|
+
transition: background 160ms ease, color 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
@media (hover: hover) {
|
|
793
|
+
.view-toggle-tab:hover {
|
|
794
|
+
transform: none;
|
|
795
|
+
color: var(--ink);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
.view-toggle-tab.is-active {
|
|
800
|
+
border-color: rgba(232, 223, 204, 0.9);
|
|
801
|
+
background: rgba(255, 252, 247, 0.96);
|
|
802
|
+
color: var(--ink);
|
|
803
|
+
box-shadow: 0 8px 20px rgba(101, 73, 18, 0.08);
|
|
804
|
+
}
|
|
805
|
+
|
|
759
806
|
.button,
|
|
760
807
|
button,
|
|
761
808
|
.link-button,
|
|
@@ -1403,6 +1403,13 @@ const videoRemoveCaptionsPrimitive = definePrimitive({
|
|
|
1403
1403
|
}
|
|
1404
1404
|
const probedDurationSeconds = Number(probeMetadata.durationSeconds ?? 0);
|
|
1405
1405
|
const durationSeconds = Number.isFinite(probedDurationSeconds) && probedDurationSeconds > 0 ? probedDurationSeconds : 30;
|
|
1406
|
+
// GhostCut is a per-FINISHED-CLIP tool, never a long-form pass. Clip
|
|
1407
|
+
// hunting handles "no captions" as a scene-selection filter (avoid_text);
|
|
1408
|
+
// after the hunt, apply caption removal to individual finished clips.
|
|
1409
|
+
if (config.GHOSTCUT_MAX_DURATION_SEC > 0 && durationSeconds > config.GHOSTCUT_MAX_DURATION_SEC) {
|
|
1410
|
+
throw new Error(`Caption removal is limited to videos up to ${Math.round(config.GHOSTCUT_MAX_DURATION_SEC / 60)} minutes (this source is ${Math.round(durationSeconds / 60)} min). ` +
|
|
1411
|
+
`For long-form video, hunt clips first (POST /clips/scan with avoid_text/no-captions guidance selects text-free scenes), then remove captions from the finished clips you keep.`);
|
|
1412
|
+
}
|
|
1406
1413
|
const cost = estimateGhostcutCostUsd(durationSeconds);
|
|
1407
1414
|
ctx.logger.progress(0.12, "Submitting caption-removal task", {
|
|
1408
1415
|
durationSeconds: Number(durationSeconds.toFixed(3)),
|
|
@@ -167,6 +167,9 @@ function resolveCostCenterSlug(input) {
|
|
|
167
167
|
|| explicitSlug === "primitive_media_lambda"
|
|
168
168
|
|| explicitSlug === "job_runner_lambda"
|
|
169
169
|
|| explicitSlug === "step_functions_standard"
|
|
170
|
+
// Clip hunting (long-form → short-form): AWS compute only — the AI
|
|
171
|
+
// tagging/embedding spend is BYOK and deliberately NOT wallet-billed.
|
|
172
|
+
|| explicitSlug === "clip_scan_lambda"
|
|
170
173
|
? explicitSlug
|
|
171
174
|
: null;
|
|
172
175
|
}
|