@mevdragon/vidfarm-devcli 0.8.0 → 0.9.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.
@@ -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
@@ -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") {
@@ -1671,6 +1671,27 @@ async function callOpenAIVision(input) {
1671
1671
  image_url: { url: `data:image/jpeg;base64,${frame.data}` }
1672
1672
  });
1673
1673
  }
1674
+ // OpenRouter proxies many models and still accepts the classic Chat
1675
+ // Completions params. Native OpenAI's current models (gpt-5.x / o-series)
1676
+ // rejected `max_tokens` (they require `max_completion_tokens`) and only allow
1677
+ // the default temperature, so sending `max_tokens`/`temperature:0.15` 400s
1678
+ // every call. Branch the body by endpoint.
1679
+ const isOpenRouter = Boolean(input.endpoint && input.endpoint.includes("openrouter"));
1680
+ // Give the JSON response room to breathe so a video with 12+ text overlays
1681
+ // doesn't get truncated (same rationale as Gemini's maxOutputTokens).
1682
+ const OUTPUT_TOKEN_BUDGET = 16384;
1683
+ const body = {
1684
+ model: input.model,
1685
+ response_format: { type: "json_object" },
1686
+ messages: [{ role: "user", content }]
1687
+ };
1688
+ if (isOpenRouter) {
1689
+ body.temperature = 0.15;
1690
+ body.max_tokens = OUTPUT_TOKEN_BUDGET;
1691
+ }
1692
+ else {
1693
+ body.max_completion_tokens = OUTPUT_TOKEN_BUDGET;
1694
+ }
1674
1695
  const response = await fetch(input.endpoint ?? "https://api.openai.com/v1/chat/completions", {
1675
1696
  method: "POST",
1676
1697
  headers: {
@@ -1678,15 +1699,7 @@ async function callOpenAIVision(input) {
1678
1699
  "authorization": `Bearer ${input.apiKey}`,
1679
1700
  ...(input.extraHeaders ?? {})
1680
1701
  },
1681
- body: JSON.stringify({
1682
- model: input.model,
1683
- temperature: 0.15,
1684
- // Same rationale as Gemini's maxOutputTokens: give the JSON response room
1685
- // to breathe so a video with 12+ text overlays doesn't get truncated.
1686
- max_tokens: 16384,
1687
- response_format: { type: "json_object" },
1688
- messages: [{ role: "user", content }]
1689
- })
1702
+ body: JSON.stringify(body)
1690
1703
  });
1691
1704
  const raw = await response.text();
1692
1705
  if (!response.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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": {