@mevdragon/vidfarm-devcli 0.8.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 +27 -1
- package/SKILL.director.md +57 -0
- package/SKILL.platform.md +13 -1
- package/demo/dist/app.js +56 -28
- package/dist/src/account-pages-legacy.js +15 -10
- package/dist/src/app.js +678 -189
- package/dist/src/cli.js +341 -54
- package/dist/src/config.js +5 -0
- package/dist/src/devcli/clips.js +312 -49
- package/dist/src/devcli/telemetry.js +236 -0
- package/dist/src/editor-chat.js +1 -1
- package/dist/src/frontend/homepage-view.js +0 -5
- package/dist/src/frontend/template-editor-chat.js +212 -0
- 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/hyperframes.js +22 -9
- 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/public/assets/page-runtime-client-app.js +31 -31
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Opt-out crash telemetry for the vidfarm devcli — the `vidfarm ...` commands
|
|
2
|
+
// AND the local `serve` editor server. Unlike the backend/browser Sentry setups
|
|
3
|
+
// (which run on OUR infrastructure), this runs on END USERS' machines, so it is
|
|
4
|
+
// built defensively:
|
|
5
|
+
//
|
|
6
|
+
// * DEDICATED DSN — never the backend or browser project. Ships EMPTY, i.e.
|
|
7
|
+
// fully DISABLED, until VIDFARM_DEVCLI_SENTRY_DSN (or the DEVCLI_SENTRY_DSN
|
|
8
|
+
// constant below) points at a Sentry project created specifically for the
|
|
9
|
+
// CLI. That keeps devcli crashes out of product error streams and means the
|
|
10
|
+
// published package never carries a DSN that could be abused for quota spam.
|
|
11
|
+
// * OPT-OUT — honors DO_NOT_TRACK (https://consoledonottrack.com) and
|
|
12
|
+
// VIDFARM_TELEMETRY=off|0|false|no|disabled.
|
|
13
|
+
// * CRASH-ONLY — deliberate `throw new Error("...")` user errors (bad args,
|
|
14
|
+
// HTTP 4xx, missing files) and transient network failures are NOT reported;
|
|
15
|
+
// only unexpected bugs (TypeError/RangeError/… and non-Error throws) are.
|
|
16
|
+
// * SCRUBBED — beforeSend strips home-dir paths, api keys, auth headers, and
|
|
17
|
+
// known env secrets before anything leaves the machine; PII is off; console
|
|
18
|
+
// breadcrumbs (which can carry prompts / composition text) are dropped.
|
|
19
|
+
// * FLUSHED — flushTelemetry() drains the queue before the short-lived CLI exits.
|
|
20
|
+
//
|
|
21
|
+
// Every entry point is wrapped so telemetry can NEVER break a user's command.
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import * as Sentry from "@sentry/node";
|
|
27
|
+
// Set this to a DSN for a Sentry project dedicated to the devcli (NOT the
|
|
28
|
+
// backend or browser project). Left empty so the published package is silent
|
|
29
|
+
// until such a project exists; VIDFARM_DEVCLI_SENTRY_DSN overrides it at runtime.
|
|
30
|
+
const DEVCLI_SENTRY_DSN = "";
|
|
31
|
+
let initialized = false;
|
|
32
|
+
// Env vars whose values must never leave the machine, even if they surface in
|
|
33
|
+
// an error message or stack. Values are collected at init and redacted verbatim.
|
|
34
|
+
const SECRET_ENV_KEYS = [
|
|
35
|
+
"VIDFARM_API_KEY",
|
|
36
|
+
"VIDFARM_STAGING_BOOTSTRAP_API_KEY",
|
|
37
|
+
"OPENAI_API_KEY",
|
|
38
|
+
"OPENROUTER_API_KEY",
|
|
39
|
+
"GEMINI_API_KEY",
|
|
40
|
+
"PERPLEXITY_API_KEY",
|
|
41
|
+
"GHOSTCUT_KEY",
|
|
42
|
+
"GHOSTCUT_SECRET",
|
|
43
|
+
"ENCRYPTION_SECRET",
|
|
44
|
+
"API_KEY_SALT",
|
|
45
|
+
"WEBHOOK_SECRET",
|
|
46
|
+
"RESEND_API_KEY",
|
|
47
|
+
"AWS_ACCESS_KEY_ID",
|
|
48
|
+
"AWS_SECRET_ACCESS_KEY"
|
|
49
|
+
];
|
|
50
|
+
const HOME_DIR = safeHomedir();
|
|
51
|
+
let secretValues = [];
|
|
52
|
+
// Token-shaped strings redacted regardless of source (belt-and-suspenders on top
|
|
53
|
+
// of the exact env-value match above).
|
|
54
|
+
const TOKEN_PATTERNS = [
|
|
55
|
+
/\b(?:vf|sk|rk|pk|key)_[A-Za-z0-9_-]{8,}\b/g, // vidfarm / provider-style keys
|
|
56
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // JWT
|
|
57
|
+
/\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
|
|
58
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi
|
|
59
|
+
];
|
|
60
|
+
// Transient / environmental failures we never want to page on — they are the
|
|
61
|
+
// user's network, not our bug.
|
|
62
|
+
const BENIGN_MESSAGE = /(fetch failed|ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|socket hang up|getaddrinfo|network|and requires a connection)/i;
|
|
63
|
+
function safeHomedir() {
|
|
64
|
+
try {
|
|
65
|
+
return os.homedir() || "";
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function isOptedOut() {
|
|
72
|
+
// DO_NOT_TRACK: any value other than unset / "0" / "false" means opt out.
|
|
73
|
+
const dnt = process.env.DO_NOT_TRACK?.trim();
|
|
74
|
+
if (dnt && dnt !== "0" && dnt.toLowerCase() !== "false")
|
|
75
|
+
return true;
|
|
76
|
+
const flag = process.env.VIDFARM_TELEMETRY?.trim().toLowerCase();
|
|
77
|
+
if (flag && ["0", "off", "false", "no", "disabled"].includes(flag))
|
|
78
|
+
return true;
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
function resolveDsn() {
|
|
82
|
+
return (process.env.VIDFARM_DEVCLI_SENTRY_DSN?.trim() || DEVCLI_SENTRY_DSN || "").trim();
|
|
83
|
+
}
|
|
84
|
+
function readPackageVersion() {
|
|
85
|
+
try {
|
|
86
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
87
|
+
for (let i = 0; i < 6; i++) {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
90
|
+
if (parsed.name === "@mevdragon/vidfarm-devcli" && parsed.version) {
|
|
91
|
+
return `vidfarm-devcli@${parsed.version}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// keep walking up
|
|
96
|
+
}
|
|
97
|
+
const parent = path.dirname(dir);
|
|
98
|
+
if (parent === dir)
|
|
99
|
+
break;
|
|
100
|
+
dir = parent;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// ignore — release tag is best-effort
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
function scrubString(input) {
|
|
109
|
+
let s = input;
|
|
110
|
+
if (HOME_DIR && s.includes(HOME_DIR))
|
|
111
|
+
s = s.split(HOME_DIR).join("~");
|
|
112
|
+
for (const secret of secretValues) {
|
|
113
|
+
if (secret && s.includes(secret))
|
|
114
|
+
s = s.split(secret).join("[REDACTED]");
|
|
115
|
+
}
|
|
116
|
+
for (const re of TOKEN_PATTERNS)
|
|
117
|
+
s = s.replace(re, "[REDACTED]");
|
|
118
|
+
// `--api-key <value>` on the command line and `header: value` shaped pairs.
|
|
119
|
+
s = s.replace(/(--api-key[=\s]+)\S+/gi, "$1[REDACTED]");
|
|
120
|
+
s = s.replace(/((?:vidfarm-api-key|authorization|x-api-key)["']?\s*[:=]\s*["']?)[^"'\s,}]+/gi, "$1[REDACTED]");
|
|
121
|
+
return s;
|
|
122
|
+
}
|
|
123
|
+
function scrubDeep(value, depth) {
|
|
124
|
+
if (value == null || depth > 8)
|
|
125
|
+
return value;
|
|
126
|
+
if (typeof value === "string")
|
|
127
|
+
return scrubString(value);
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
for (let i = 0; i < value.length; i++)
|
|
130
|
+
value[i] = scrubDeep(value[i], depth + 1);
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
if (typeof value === "object") {
|
|
134
|
+
const record = value;
|
|
135
|
+
for (const key of Object.keys(record))
|
|
136
|
+
record[key] = scrubDeep(record[key], depth + 1);
|
|
137
|
+
return record;
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
/** Deliberate `throw new Error("...")` user errors have name "Error"; genuine
|
|
142
|
+
* bugs surface as TypeError/RangeError/ReferenceError/… or as non-Error throws.
|
|
143
|
+
* Transient network failures are treated as expected. */
|
|
144
|
+
export function isUnexpectedCrash(error) {
|
|
145
|
+
if (!(error instanceof Error))
|
|
146
|
+
return true;
|
|
147
|
+
if (BENIGN_MESSAGE.test(error.message))
|
|
148
|
+
return false;
|
|
149
|
+
return error.name !== "Error";
|
|
150
|
+
}
|
|
151
|
+
export function isTelemetryEnabled() {
|
|
152
|
+
return initialized;
|
|
153
|
+
}
|
|
154
|
+
export function initTelemetry(commandName) {
|
|
155
|
+
try {
|
|
156
|
+
if (initialized)
|
|
157
|
+
return;
|
|
158
|
+
if (isOptedOut())
|
|
159
|
+
return;
|
|
160
|
+
const dsn = resolveDsn();
|
|
161
|
+
if (!dsn)
|
|
162
|
+
return;
|
|
163
|
+
secretValues = SECRET_ENV_KEYS.map((key) => process.env[key]?.trim() ?? "").filter((v) => v.length >= 8);
|
|
164
|
+
Sentry.init({
|
|
165
|
+
dsn,
|
|
166
|
+
environment: "devcli",
|
|
167
|
+
release: process.env.VIDFARM_DEVCLI_RELEASE?.trim() || readPackageVersion(),
|
|
168
|
+
// Short-lived CLI: no performance tracing, no log capture (logs can carry
|
|
169
|
+
// prompts / composition text), tiny breadcrumb budget.
|
|
170
|
+
tracesSampleRate: 0,
|
|
171
|
+
enableLogs: false,
|
|
172
|
+
sendDefaultPii: false,
|
|
173
|
+
maxBreadcrumbs: 20,
|
|
174
|
+
beforeBreadcrumb(breadcrumb) {
|
|
175
|
+
if (breadcrumb.category === "console")
|
|
176
|
+
return null;
|
|
177
|
+
try {
|
|
178
|
+
return scrubDeep(breadcrumb, 0);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null; // if scrubbing fails, drop rather than risk leaking
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
beforeSend(event) {
|
|
185
|
+
try {
|
|
186
|
+
if (event.request) {
|
|
187
|
+
delete event.request.data;
|
|
188
|
+
delete event.request.cookies;
|
|
189
|
+
delete event.request.headers;
|
|
190
|
+
}
|
|
191
|
+
return scrubDeep(event, 0);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null; // scrub failed → drop the event entirely, never leak raw
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
Sentry.setTags({
|
|
199
|
+
"vidfarm.surface": "devcli",
|
|
200
|
+
"vidfarm.node": process.version,
|
|
201
|
+
"vidfarm.platform": `${process.platform}-${process.arch}`,
|
|
202
|
+
...(commandName ? { "vidfarm.command": commandName } : {})
|
|
203
|
+
});
|
|
204
|
+
initialized = true;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Telemetry must never break the CLI.
|
|
208
|
+
initialized = false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Report an unexpected top-level CLI crash (no-op for expected user errors,
|
|
212
|
+
* transient network errors, or when telemetry is disabled), then flush. */
|
|
213
|
+
export async function reportCliCrash(error) {
|
|
214
|
+
try {
|
|
215
|
+
if (!initialized)
|
|
216
|
+
return;
|
|
217
|
+
if (!isUnexpectedCrash(error))
|
|
218
|
+
return;
|
|
219
|
+
Sentry.captureException(error);
|
|
220
|
+
await flushTelemetry();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// swallow — never let telemetry change the exit path
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
export async function flushTelemetry(timeoutMs = 2000) {
|
|
227
|
+
try {
|
|
228
|
+
if (!initialized)
|
|
229
|
+
return;
|
|
230
|
+
await Sentry.flush(timeoutMs);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// swallow
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=telemetry.js.map
|
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
|
|
@@ -3501,6 +3501,213 @@ export function TemplateEditorChat({ boot }) {
|
|
|
3501
3501
|
}
|
|
3502
3502
|
await appendFiles(files);
|
|
3503
3503
|
}
|
|
3504
|
+
// "create me a video of…" / "replicate this URL…" — the discover-chat-only
|
|
3505
|
+
// create_video tool returns the user's intent; we run the SAME pipeline the
|
|
3506
|
+
// devcli `create`/`replicate` commands run, here in the browser, updating this
|
|
3507
|
+
// assistant message as it progresses and appending an "Open in editor" link
|
|
3508
|
+
// when the new video is ready. Long-running but visible; all same-origin API
|
|
3509
|
+
// calls (the source download happens server-side in the ingest primitive).
|
|
3510
|
+
async function runCreateVideoFlow(threadId, messageId, intentRaw) {
|
|
3511
|
+
const intent = intentRaw ?? {};
|
|
3512
|
+
const apiKey = boot.vidfarmApiKey;
|
|
3513
|
+
const origin = window.location.origin;
|
|
3514
|
+
const jsonHeaders = buildAuthHeaders(apiKey, "application/json");
|
|
3515
|
+
const getHeaders = buildAuthHeaders(apiKey);
|
|
3516
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3517
|
+
const patch = (status, appendText) => {
|
|
3518
|
+
updateThread(threadId, (thread) => ({
|
|
3519
|
+
...thread,
|
|
3520
|
+
updatedAt: new Date().toISOString(),
|
|
3521
|
+
messages: thread.messages.map((message) => (message.id === messageId
|
|
3522
|
+
? {
|
|
3523
|
+
...message,
|
|
3524
|
+
isPending: false,
|
|
3525
|
+
statusText: status ?? message.statusText,
|
|
3526
|
+
text: appendText ? appendDistinctMessageText(message.text, appendText) : message.text
|
|
3527
|
+
}
|
|
3528
|
+
: message))
|
|
3529
|
+
}));
|
|
3530
|
+
};
|
|
3531
|
+
const readJson = async (res) => {
|
|
3532
|
+
const text = await res.text();
|
|
3533
|
+
try {
|
|
3534
|
+
return text ? JSON.parse(text) : null;
|
|
3535
|
+
}
|
|
3536
|
+
catch {
|
|
3537
|
+
return null;
|
|
3538
|
+
}
|
|
3539
|
+
};
|
|
3540
|
+
const pollInspirationReady = async (inspirationId) => {
|
|
3541
|
+
const deadline = Date.now() + 5 * 60 * 1000;
|
|
3542
|
+
for (;;) {
|
|
3543
|
+
const res = await fetch(new URL(`/api/v1/videos/${encodeURIComponent(inspirationId)}`, origin), { headers: getHeaders });
|
|
3544
|
+
const json = await readJson(res);
|
|
3545
|
+
const status = String(json?.status ?? "");
|
|
3546
|
+
if (status === "ready")
|
|
3547
|
+
return json;
|
|
3548
|
+
if (status === "failed")
|
|
3549
|
+
throw new Error(`source video download failed${json?.error ? `: ${json.error}` : ""}.`);
|
|
3550
|
+
if (Date.now() > deadline)
|
|
3551
|
+
throw new Error("source video download timed out.");
|
|
3552
|
+
await sleep(5000);
|
|
3553
|
+
}
|
|
3554
|
+
};
|
|
3555
|
+
const resolveJobMediaUrl = (job) => {
|
|
3556
|
+
const readStr = (value) => (typeof value === "string" && value.trim() ? value.trim() : null);
|
|
3557
|
+
const result = (job?.result && typeof job.result === "object") ? job.result : {};
|
|
3558
|
+
const output = (result.output && typeof result.output === "object") ? result.output : result;
|
|
3559
|
+
const direct = readStr(output?.primary_file_url) ?? readStr(output?.video?.file_url) ?? readStr(output?.image?.file_url) ?? readStr(output?.render?.output_url);
|
|
3560
|
+
if (direct)
|
|
3561
|
+
return direct;
|
|
3562
|
+
if (Array.isArray(output?.files)) {
|
|
3563
|
+
for (const file of output.files) {
|
|
3564
|
+
const url = readStr(typeof file === "string" ? file : (file?.file_url ?? file?.url));
|
|
3565
|
+
if (url)
|
|
3566
|
+
return url;
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
if (Array.isArray(job?.artifacts)) {
|
|
3570
|
+
for (const artifact of job.artifacts) {
|
|
3571
|
+
const url = readStr(artifact?.public_url);
|
|
3572
|
+
if (url)
|
|
3573
|
+
return url;
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
return null;
|
|
3577
|
+
};
|
|
3578
|
+
const pollJobMedia = async (jobId) => {
|
|
3579
|
+
const deadline = Date.now() + 8 * 60 * 1000;
|
|
3580
|
+
for (;;) {
|
|
3581
|
+
await sleep(5000);
|
|
3582
|
+
const res = await fetch(new URL(`/api/v1/user/me/jobs/${encodeURIComponent(jobId)}`, origin), { headers: getHeaders });
|
|
3583
|
+
const json = await readJson(res);
|
|
3584
|
+
const status = String(json?.status ?? "");
|
|
3585
|
+
const url = resolveJobMediaUrl(json);
|
|
3586
|
+
if (url)
|
|
3587
|
+
return url;
|
|
3588
|
+
if (status === "failed" || status === "cancelled")
|
|
3589
|
+
throw new Error(`generation ${status}.`);
|
|
3590
|
+
if (Date.now() > deadline)
|
|
3591
|
+
throw new Error("generation timed out.");
|
|
3592
|
+
}
|
|
3593
|
+
};
|
|
3594
|
+
// Wrap a generated MP4 into a new template via the upload-ingest path (the
|
|
3595
|
+
// same route `inspiration-add <file>` uses). The generated media lives on
|
|
3596
|
+
// public-read storage (bucket CORS allows a browser GET), so we can pull the
|
|
3597
|
+
// blob and re-upload it.
|
|
3598
|
+
const ingestGeneratedVideo = async (mediaUrl, title) => {
|
|
3599
|
+
const blob = await (await fetch(mediaUrl)).blob();
|
|
3600
|
+
const presignRes = await fetch(new URL("/discover/templates/upload/presign", origin), {
|
|
3601
|
+
method: "POST",
|
|
3602
|
+
headers: jsonHeaders,
|
|
3603
|
+
body: JSON.stringify({ file_name: "generated.mp4", content_type: "video/mp4", size_bytes: blob.size })
|
|
3604
|
+
});
|
|
3605
|
+
const presign = await readJson(presignRes);
|
|
3606
|
+
if (!presignRes.ok)
|
|
3607
|
+
throw new Error(presign?.error || `upload presign failed (${presignRes.status})`);
|
|
3608
|
+
let storageKey = presign?.storage_key;
|
|
3609
|
+
let uploadedName = presign?.file_name || "generated.mp4";
|
|
3610
|
+
if (presign?.transport === "presigned" && presign?.upload?.url) {
|
|
3611
|
+
const put = await fetch(presign.upload.url, { method: presign.upload.method || "PUT", headers: presign.upload.headers || {}, body: blob });
|
|
3612
|
+
if (!put.ok)
|
|
3613
|
+
throw new Error(`upload failed (${put.status})`);
|
|
3614
|
+
}
|
|
3615
|
+
else {
|
|
3616
|
+
const form = new FormData();
|
|
3617
|
+
form.append("file", blob, "generated.mp4");
|
|
3618
|
+
const up = await fetch(new URL(presign?.upload?.url || "/discover/templates/upload", origin), { method: "POST", headers: getHeaders, body: form });
|
|
3619
|
+
const upJson = await readJson(up);
|
|
3620
|
+
if (!up.ok || !upJson?.storage_key)
|
|
3621
|
+
throw new Error(upJson?.error || `upload failed (${up.status})`);
|
|
3622
|
+
storageKey = upJson.storage_key;
|
|
3623
|
+
uploadedName = upJson.file_name || "generated.mp4";
|
|
3624
|
+
}
|
|
3625
|
+
if (!storageKey)
|
|
3626
|
+
throw new Error("upload did not return a storage key.");
|
|
3627
|
+
const addRes = await fetch(new URL("/discover/templates", origin), {
|
|
3628
|
+
method: "POST",
|
|
3629
|
+
headers: jsonHeaders,
|
|
3630
|
+
body: JSON.stringify({ upload: { storage_key: storageKey, file_name: uploadedName }, title })
|
|
3631
|
+
});
|
|
3632
|
+
const add = await readJson(addRes);
|
|
3633
|
+
if (!addRes.ok)
|
|
3634
|
+
throw new Error(add?.error || `ingest failed (${addRes.status})`);
|
|
3635
|
+
return add;
|
|
3636
|
+
};
|
|
3637
|
+
try {
|
|
3638
|
+
if (!apiKey) {
|
|
3639
|
+
patch(null, "⚠️ You need to be logged in to create a video.");
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3642
|
+
const mode = intent.mode === "replicate" ? "replicate" : intent.mode === "generate" ? "generate" : null;
|
|
3643
|
+
let inspiration;
|
|
3644
|
+
let userPrompt;
|
|
3645
|
+
if (mode === "replicate") {
|
|
3646
|
+
const sourceUrl = (intent.source_url ?? "").trim();
|
|
3647
|
+
if (!/^https?:\/\//i.test(sourceUrl)) {
|
|
3648
|
+
patch(null, "⚠️ I need a video URL (TikTok/YouTube/Instagram/X) to replicate.");
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
userPrompt = (intent.instructions ?? "").trim() || null;
|
|
3652
|
+
patch("Fetching the source video…");
|
|
3653
|
+
const addRes = await fetch(new URL("/discover/templates", origin), { method: "POST", headers: jsonHeaders, body: JSON.stringify({ source_url: sourceUrl }) });
|
|
3654
|
+
const add = await readJson(addRes);
|
|
3655
|
+
if (!addRes.ok)
|
|
3656
|
+
throw new Error(add?.error || `couldn't fetch that URL (${addRes.status})`);
|
|
3657
|
+
inspiration = add?.status === "ready" ? add : await pollInspirationReady(add?.inspiration_id);
|
|
3658
|
+
}
|
|
3659
|
+
else if (mode === "generate") {
|
|
3660
|
+
const prompt = (intent.prompt ?? "").trim();
|
|
3661
|
+
if (!prompt) {
|
|
3662
|
+
patch(null, "⚠️ Tell me what the video should be about and I'll create it.");
|
|
3663
|
+
return;
|
|
3664
|
+
}
|
|
3665
|
+
userPrompt = prompt;
|
|
3666
|
+
patch("Generating your video… (this can take a couple of minutes)");
|
|
3667
|
+
const genRes = await fetch(new URL("/api/v1/primitives/videos/generate", origin), {
|
|
3668
|
+
method: "POST",
|
|
3669
|
+
headers: jsonHeaders,
|
|
3670
|
+
body: JSON.stringify({ tracer: `chat-create-${Date.now().toString(36)}`, payload: { prompt } })
|
|
3671
|
+
});
|
|
3672
|
+
const gen = await readJson(genRes);
|
|
3673
|
+
if (!genRes.ok)
|
|
3674
|
+
throw new Error(gen?.error || `couldn't start generation (${genRes.status})`);
|
|
3675
|
+
const mediaUrl = await pollJobMedia(gen?.job_id);
|
|
3676
|
+
patch("Building an editable template from the generated video…");
|
|
3677
|
+
const add = await ingestGeneratedVideo(mediaUrl, prompt.slice(0, 80));
|
|
3678
|
+
inspiration = add?.status === "ready" ? add : await pollInspirationReady(add?.inspiration_id);
|
|
3679
|
+
}
|
|
3680
|
+
else {
|
|
3681
|
+
patch(null, "⚠️ I couldn't tell whether to generate a new video or replicate a URL — try rephrasing.");
|
|
3682
|
+
return;
|
|
3683
|
+
}
|
|
3684
|
+
const templateId = inspiration?.template_id;
|
|
3685
|
+
const inspirationId = inspiration?.inspiration_id;
|
|
3686
|
+
if (!templateId || !inspirationId)
|
|
3687
|
+
throw new Error("the template wasn't created from the video.");
|
|
3688
|
+
patch(mode === "replicate" ? "Recreating the scenes… (about a minute)" : "Analyzing into an editable timeline…");
|
|
3689
|
+
const decRes = await fetch(new URL(`/api/v1/inspirations/${encodeURIComponent(inspirationId)}/decompose`, origin), {
|
|
3690
|
+
method: "POST",
|
|
3691
|
+
headers: jsonHeaders,
|
|
3692
|
+
body: JSON.stringify({ user_prompt: userPrompt })
|
|
3693
|
+
});
|
|
3694
|
+
if (!decRes.ok) {
|
|
3695
|
+
const dec = await readJson(decRes);
|
|
3696
|
+
throw new Error(dec?.error || `scene analysis failed (${decRes.status})`);
|
|
3697
|
+
}
|
|
3698
|
+
patch("Finalizing your editable copy…");
|
|
3699
|
+
const forkRes = await fetch(new URL("/api/v1/compositions", origin), { method: "POST", headers: jsonHeaders, body: JSON.stringify({ template_id: templateId }) });
|
|
3700
|
+
const fork = await readJson(forkRes);
|
|
3701
|
+
if (!forkRes.ok)
|
|
3702
|
+
throw new Error(fork?.error || `couldn't create an editable copy (${forkRes.status})`);
|
|
3703
|
+
const forkId = fork?.fork_id;
|
|
3704
|
+
const editorUrl = `${origin}/editor/${encodeURIComponent(templateId)}${forkId ? `?fork=${encodeURIComponent(forkId)}` : ""}`;
|
|
3705
|
+
patch("Your video is ready.", `✅ Your video is ready — [Open it in the editor](${editorUrl}).`);
|
|
3706
|
+
}
|
|
3707
|
+
catch (error) {
|
|
3708
|
+
patch(null, `⚠️ I couldn't finish creating the video: ${error instanceof Error ? error.message : String(error)}`);
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3504
3711
|
async function handleSend() {
|
|
3505
3712
|
if (!canSend) {
|
|
3506
3713
|
return;
|
|
@@ -3661,6 +3868,11 @@ export function TemplateEditorChat({ boot }) {
|
|
|
3661
3868
|
onToolResult: (toolResult) => {
|
|
3662
3869
|
const sanitizedToolResult = sanitizeToolResult(toolResult.result);
|
|
3663
3870
|
const pendingCall = pendingToolCalls.get(toolResult.toolCallId);
|
|
3871
|
+
if (toolResult.toolName === "create_video") {
|
|
3872
|
+
// Fire-and-forget: run the ingest→decompose→fork pipeline in the
|
|
3873
|
+
// browser, streaming progress into this assistant message.
|
|
3874
|
+
void runCreateVideoFlow(threadId, assistantMessage.id, toolResult.result);
|
|
3875
|
+
}
|
|
3664
3876
|
if (toolResult.toolName === "frontend_action") {
|
|
3665
3877
|
const actionResult = toolResult.result;
|
|
3666
3878
|
if (actionResult?.action === "add_tracer") {
|
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
|
}
|