vidistill 0.2.0 → 0.2.2

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.
Files changed (2) hide show
  1. package/dist/index.js +306 -202
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
+ var __glob = (map) => (path) => {
3
+ var fn = map[path];
4
+ if (fn) return fn();
5
+ throw new Error("Module not found in bundle: " + path);
6
+ };
2
7
 
3
8
  // src/cli/index.ts
9
+ import { createRequire as createRequire2 } from "module";
4
10
  import { defineCommand, runMain } from "citty";
5
- import { log as log10 } from "@clack/prompts";
6
- import pc5 from "picocolors";
7
- import { basename as basename3, extname as extname2, resolve } from "path";
8
11
 
9
12
  // src/cli/ui.ts
10
13
  import figlet from "figlet";
11
14
  import pc from "picocolors";
12
- import { intro, log } from "@clack/prompts";
15
+ import { intro, note } from "@clack/prompts";
13
16
  function showLogo() {
14
17
  const ascii = figlet.textSync("VIDISTILL", { font: "Big" });
15
18
  console.log(pc.cyan(ascii));
@@ -17,17 +20,22 @@ function showLogo() {
17
20
  function showIntro() {
18
21
  intro(pc.dim("video intelligence distiller"));
19
22
  }
20
- function showConfig(config) {
23
+ function showConfigBox(config) {
21
24
  const lines = [
22
- ` input ${pc.cyan(config.input)}`,
23
- ` context ${config.context ? pc.white(config.context) : pc.dim("(none)")}`,
24
- ` output ${pc.white(config.output)}`
25
+ `Video: ${config.input}`,
26
+ `Context: ${config.context ?? "(none)"}`,
27
+ `Output: ${config.output}`
25
28
  ];
26
- log.message(lines.join("\n"), { symbol: pc.green("\xBB") });
29
+ note(lines.join("\n"), "Configuration");
27
30
  }
28
31
 
32
+ // src/commands/distill.ts
33
+ import { log as log8, cancel as cancel2 } from "@clack/prompts";
34
+ import pc4 from "picocolors";
35
+ import { basename as basename3, extname as extname2, resolve } from "path";
36
+
29
37
  // src/cli/prompts.ts
30
- import { text, password, confirm, isCancel, cancel } from "@clack/prompts";
38
+ import { text, password, confirm, select, isCancel, cancel } from "@clack/prompts";
31
39
  function handleCancel(value) {
32
40
  if (isCancel(value)) {
33
41
  cancel("Operation cancelled.");
@@ -39,7 +47,7 @@ async function promptVideoSource() {
39
47
  message: "YouTube URL or local file path",
40
48
  placeholder: "https://youtube.com/watch?v=...",
41
49
  validate(input) {
42
- if (input.trim().length === 0) {
50
+ if (!input || input.trim().length === 0) {
43
51
  return "A video source is required.";
44
52
  }
45
53
  }
@@ -60,7 +68,7 @@ async function promptApiKey() {
60
68
  const value = await password({
61
69
  message: "Gemini API key",
62
70
  validate(input) {
63
- if (input.trim().length === 0) {
71
+ if (!input || input.trim().length === 0) {
64
72
  return "An API key is required.";
65
73
  }
66
74
  }
@@ -76,12 +84,25 @@ async function promptSaveKey() {
76
84
  handleCancel(value);
77
85
  return value;
78
86
  }
87
+ async function promptConfirmation() {
88
+ const value = await select({
89
+ message: "Ready to process?",
90
+ options: [
91
+ { value: "start", label: "Start processing" },
92
+ { value: "edit-video", label: "Edit video source" },
93
+ { value: "edit-context", label: "Edit context" },
94
+ { value: "cancel", label: "Cancel" }
95
+ ]
96
+ });
97
+ handleCancel(value);
98
+ return value;
99
+ }
79
100
 
80
101
  // src/cli/config.ts
81
102
  import { promises as fs } from "fs";
82
103
  import { join } from "path";
83
104
  import os from "os";
84
- import { log as log2 } from "@clack/prompts";
105
+ import { log } from "@clack/prompts";
85
106
  import pc2 from "picocolors";
86
107
 
87
108
  // src/gemini/client.ts
@@ -187,12 +208,12 @@ async function saveConfig(config) {
187
208
  async function resolveApiKey() {
188
209
  const envKey = process.env["GEMINI_API_KEY"];
189
210
  if (envKey && envKey.trim().length > 0) {
190
- log2.info(pc2.dim("(using GEMINI_API_KEY from environment)"));
211
+ log.info(pc2.dim("(using GEMINI_API_KEY from environment)"));
191
212
  return envKey.trim();
192
213
  }
193
214
  const config = await loadConfig();
194
215
  if (config?.apiKey && config.apiKey.trim().length > 0) {
195
- log2.info(pc2.dim("(using API key from ~/.vidistill/config.json)"));
216
+ log.info(pc2.dim("(using API key from ~/.vidistill/config.json)"));
196
217
  return config.apiKey.trim();
197
218
  }
198
219
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
@@ -200,9 +221,9 @@ async function resolveApiKey() {
200
221
  const client = new GeminiClient(key);
201
222
  const valid = await client.validateKey();
202
223
  if (!valid) {
203
- log2.error(pc2.red("Invalid API key"));
224
+ log.error(pc2.red("Invalid API key"));
204
225
  if (attempt === MAX_ATTEMPTS) {
205
- log2.error(pc2.red("Maximum attempts reached. Exiting."));
226
+ log.error(pc2.red("Maximum attempts reached. Exiting."));
206
227
  process.exit(1);
207
228
  }
208
229
  continue;
@@ -218,64 +239,50 @@ async function resolveApiKey() {
218
239
  }
219
240
 
220
241
  // src/cli/progress.ts
221
- import { spinner, log as log3 } from "@clack/prompts";
222
- import pc3 from "picocolors";
242
+ import { spinner, progress } from "@clack/prompts";
243
+ var PHASE_LABELS = {
244
+ pass0: "Understanding your video...",
245
+ pass1: "Extracting transcript...",
246
+ pass2: "Analyzing visuals...",
247
+ pass3a: "Reconstructing code...",
248
+ pass3b: "Identifying participants...",
249
+ pass3c: "Reading chat messages...",
250
+ pass3d: "Detecting insights...",
251
+ synthesis: "Synthesizing notes...",
252
+ output: "Writing output files..."
253
+ };
223
254
  function createProgressDisplay() {
224
255
  const s = spinner();
225
- s.start("Starting pipeline...");
256
+ s.start(PHASE_LABELS.pass0);
257
+ let progressBar = null;
258
+ let seenTotalSteps = false;
226
259
  function update(status) {
227
- const segNum = status.segment + 1;
228
- const total = status.totalSegments;
260
+ const label = PHASE_LABELS[status.phase] ?? status.phase;
229
261
  if (status.phase === "pass0") {
230
- s.message("Analyzing video...");
231
- } else if (status.phase === "pass1") {
232
- s.message(`Pass 1: Transcript (${segNum}/${total} segments)`);
233
- } else if (status.phase === "pass2") {
234
- s.message(`Pass 2: Visual extraction (${segNum}/${total} segments)`);
235
- } else if (status.phase === "pass3a") {
236
- if (total > 1) {
237
- s.message(`Reconstructing code (run ${segNum}/${total})...`);
238
- } else {
239
- s.message("Reconstructing code...");
240
- }
241
- } else if (status.phase === "pass3b") {
242
- s.message("People extraction...");
243
- } else if (status.phase === "pass3c") {
244
- s.message(`Chat extraction (${segNum}/${total} segments)`);
245
- } else if (status.phase === "pass3d") {
246
- s.message(`Implicit signals (${segNum}/${total} segments)`);
247
- } else if (status.phase === "synthesis") {
248
- s.message("Synthesizing results...");
249
- } else if (status.phase === "output") {
250
- s.message("Generating output files...");
251
- } else {
252
- s.message(`${status.phase} (${segNum}/${total} segments)`);
262
+ s.message(label);
263
+ return;
253
264
  }
254
- }
255
- function onWait(delayMs) {
256
- const secs = Math.ceil(delayMs / 1e3);
257
- s.message(`Waiting for rate limit... (${secs}s)`);
258
- }
259
- function complete(result, elapsedMs) {
260
- const elapsedSecs = Math.round(elapsedMs / 1e3);
261
- const mins = Math.floor(elapsedSecs / 60);
262
- const secs = elapsedSecs % 60;
263
- const elapsed = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
264
- const errorCount = result.errors.length;
265
- if (errorCount > 0) {
266
- s.stop(pc3.yellow("Pipeline complete (with errors)"));
267
- } else {
268
- s.stop(pc3.green("Pipeline complete"));
265
+ if (!seenTotalSteps && status.totalSteps != null) {
266
+ seenTotalSteps = true;
267
+ s.stop("");
268
+ progressBar = progress({ max: status.totalSteps });
269
269
  }
270
- log3.info(`Segments processed: ${pc3.cyan(String(result.segments.length))}`);
271
- log3.info(`Passes run: ${pc3.cyan(result.passesRun.join(", "))}`);
272
- log3.info(`Time elapsed: ${pc3.cyan(elapsed)}`);
273
- if (errorCount > 0) {
274
- log3.warn(`Errors: ${pc3.yellow(String(errorCount))}`);
270
+ if (progressBar != null) {
271
+ if (status.status === "done" && status.currentStep != null) {
272
+ progressBar.advance(1, label);
273
+ }
275
274
  } else {
276
- log3.success("All segments completed successfully");
275
+ if (status.status === "done" && status.currentStep != null && status.totalSteps != null) {
276
+ s.message(`${label} (${status.currentStep}/${status.totalSteps})`);
277
+ } else {
278
+ s.message(label);
279
+ }
277
280
  }
278
281
  }
282
+ function onWait(_delayMs) {
283
+ }
284
+ function complete(_result, _elapsedMs) {
285
+ }
279
286
  return { update, onWait, complete };
280
287
  }
281
288
 
@@ -332,8 +339,8 @@ import { tmpdir } from "os";
332
339
  import { join as join2 } from "path";
333
340
  import { unlink } from "fs/promises";
334
341
  import { YtDlp } from "ytdlp-nodejs";
335
- import { log as log4 } from "@clack/prompts";
336
- import pc4 from "picocolors";
342
+ import { log as log2 } from "@clack/prompts";
343
+ import pc3 from "picocolors";
337
344
 
338
345
  // src/gemini/models.ts
339
346
  var MODELS = {
@@ -377,7 +384,7 @@ async function handleYouTube(url, client) {
377
384
  });
378
385
  return { fileUri: url, mimeType: "video/mp4", source: "direct" };
379
386
  } catch (err) {
380
- log4.warn(pc4.dim("Direct Gemini probe failed. Falling back to yt-dlp."));
387
+ log2.warn(pc3.dim("Direct Gemini probe failed. Falling back to yt-dlp."));
381
388
  }
382
389
  const tempPath2 = await downloadWithYtDlp(url);
383
390
  try {
@@ -437,7 +444,7 @@ import { existsSync as existsSync2, statSync, openSync, readSync, closeSync, unl
437
444
  import * as childProc from "child_process";
438
445
  import { tmpdir as tmpdir2 } from "os";
439
446
  import { join as join3, extname, basename } from "path";
440
- import { log as log5 } from "@clack/prompts";
447
+ import { log as log3 } from "@clack/prompts";
441
448
  var GB = 1024 * 1024 * 1024;
442
449
  var SIZE_3GB = 3 * GB;
443
450
  var SIZE_2GB = 2 * GB;
@@ -533,7 +540,7 @@ function convertMkvToMp4(inputPath) {
533
540
  }
534
541
  function compressTo720p(inputPath) {
535
542
  const output = tempPath(".mp4");
536
- log5.info("Compressing video to 720p...");
543
+ log3.info("Compressing video to 720p...");
537
544
  try {
538
545
  childProc.execFileSync(
539
546
  "ffmpeg",
@@ -596,7 +603,7 @@ async function handleLocalFile(filePath, client) {
596
603
 
597
604
  // src/input/duration.ts
598
605
  import { createRequire } from "module";
599
- import { log as log6 } from "@clack/prompts";
606
+ import { log as log4 } from "@clack/prompts";
600
607
  var _require = createRequire(import.meta.url);
601
608
  var ffmpeg = _require("fluent-ffmpeg");
602
609
  var BYTES_PER_SECOND = 5e5;
@@ -625,7 +632,7 @@ async function detectDuration(source) {
625
632
  const message = err instanceof Error ? err.message : String(err);
626
633
  const isNotFound = /ENOENT|not found|no such file|spawn.*ffprobe|Cannot find/i.test(message);
627
634
  if (isNotFound) {
628
- log6.warn(
635
+ log4.warn(
629
636
  "ffprobe not found \u2014 video duration will be estimated. Install: brew install ffmpeg"
630
637
  );
631
638
  }
@@ -649,7 +656,7 @@ async function detectDuration(source) {
649
656
  bytes = source.fileSize;
650
657
  }
651
658
  if (bytes !== void 0 && bytes > 0) {
652
- log6.warn(
659
+ log4.warn(
653
660
  "Duration estimated from file size \u2014 segmentation may be inaccurate"
654
661
  );
655
662
  return Math.max(1, Math.round(bytes / BYTES_PER_SECOND));
@@ -658,7 +665,7 @@ async function detectDuration(source) {
658
665
  }
659
666
 
660
667
  // src/core/pipeline.ts
661
- import { log as log8 } from "@clack/prompts";
668
+ import { log as log6 } from "@clack/prompts";
662
669
 
663
670
  // src/constants/prompts.ts
664
671
  var SYSTEM_INSTRUCTION_PASS_1 = `
@@ -1896,7 +1903,7 @@ async function runSynthesis(params) {
1896
1903
  }
1897
1904
 
1898
1905
  // src/core/strategy.ts
1899
- var BASE_PASSES = ["transcript", "visual", "synthesis"];
1906
+ var BASE_PASSES = ["transcript", "visual"];
1900
1907
  function determineStrategy(profile) {
1901
1908
  const passes = new Set(BASE_PASSES);
1902
1909
  const { type, visualContent, audioContent, complexity, recommendations } = profile;
@@ -1936,6 +1943,7 @@ function determineStrategy(profile) {
1936
1943
  }
1937
1944
  const resolution = recommendations.resolution ?? "medium";
1938
1945
  const segmentMinutes = complexity === "complex" && recommendations.segmentMinutes > 8 ? 8 : recommendations.segmentMinutes;
1946
+ passes.add("synthesis");
1939
1947
  return {
1940
1948
  passes: Array.from(passes),
1941
1949
  resolution,
@@ -1991,7 +1999,7 @@ function createSegmentPlan(durationSeconds, options) {
1991
1999
  }
1992
2000
 
1993
2001
  // src/core/consensus.ts
1994
- import { log as log7 } from "@clack/prompts";
2002
+ import { log as log5 } from "@clack/prompts";
1995
2003
  function tokenize(content) {
1996
2004
  const tokens = content.match(/[\p{L}\p{N}_]+/gu) ?? [];
1997
2005
  return new Set(tokens);
@@ -2069,7 +2077,7 @@ async function runCodeConsensus(params) {
2069
2077
  successfulRuns.push(result);
2070
2078
  } catch (e) {
2071
2079
  const msg = e instanceof Error ? e.message : String(e);
2072
- log7.warn(`consensus run ${i + 1}/${runs} failed: ${msg}`);
2080
+ log5.warn(`consensus run ${i + 1}/${runs} failed: ${msg}`);
2073
2081
  }
2074
2082
  onProgress?.(i + 1, runs);
2075
2083
  }
@@ -2293,7 +2301,7 @@ async function runPipeline(config) {
2293
2301
  "pass0"
2294
2302
  );
2295
2303
  if (pass0Attempt.error !== null) {
2296
- log8.warn(pass0Attempt.error);
2304
+ log6.warn(pass0Attempt.error);
2297
2305
  errors.push(pass0Attempt.error);
2298
2306
  videoProfile = DEFAULT_PROFILE;
2299
2307
  } else {
@@ -2301,8 +2309,6 @@ async function runPipeline(config) {
2301
2309
  }
2302
2310
  strategy = determineStrategy(videoProfile);
2303
2311
  onProgress?.({ phase: "pass0", segment: 0, totalSegments: 1, status: "done" });
2304
- log8.info(`Video type: ${videoProfile.type}`);
2305
- log8.info(`Strategy: ${strategy.passes.join(" \u2192 ")}`);
2306
2312
  const plan = createSegmentPlan(duration, {
2307
2313
  segmentMinutes: strategy.segmentMinutes,
2308
2314
  resolution: strategy.resolution
@@ -2311,6 +2317,10 @@ async function runPipeline(config) {
2311
2317
  const resolution = plan.resolution;
2312
2318
  const results = [];
2313
2319
  const n = segments.length;
2320
+ const callsPerSegment = 2 + (strategy.passes.includes("chat") ? 1 : 0) + (strategy.passes.includes("implicit") ? 1 : 0);
2321
+ const postSegmentCalls = (strategy.passes.includes("people") ? 1 : 0) + (strategy.passes.includes("code") ? 3 : 0) + (strategy.passes.includes("synthesis") ? 1 : 0);
2322
+ const totalSteps = n * callsPerSegment + postSegmentCalls;
2323
+ let currentStep = 0;
2314
2324
  let pass1RanOnce = false;
2315
2325
  let pass2RanOnce = false;
2316
2326
  let pass3cRanOnce = false;
@@ -2324,21 +2334,22 @@ async function runPipeline(config) {
2324
2334
  break;
2325
2335
  }
2326
2336
  const segment = segments[i];
2327
- onProgress?.({ phase: "pass1", segment: i, totalSegments: n, status: "running" });
2337
+ onProgress?.({ phase: "pass1", segment: i, totalSegments: n, status: "running", totalSteps });
2328
2338
  let pass1 = null;
2329
2339
  const pass1Attempt = await withRetry(
2330
2340
  () => rateLimiter.execute(() => runTranscript({ client, fileUri, mimeType, segment, model, resolution }), { onWait }),
2331
2341
  `segment ${i} pass1`
2332
2342
  );
2333
2343
  if (pass1Attempt.error !== null) {
2334
- log8.warn(pass1Attempt.error);
2344
+ log6.warn(pass1Attempt.error);
2335
2345
  errors.push(pass1Attempt.error);
2336
2346
  } else {
2337
2347
  pass1 = pass1Attempt.result;
2338
2348
  pass1RanOnce = true;
2339
2349
  }
2340
- onProgress?.({ phase: "pass1", segment: i, totalSegments: n, status: "done" });
2341
- onProgress?.({ phase: "pass2", segment: i, totalSegments: n, status: "running" });
2350
+ currentStep++;
2351
+ onProgress?.({ phase: "pass1", segment: i, totalSegments: n, status: "done", currentStep, totalSteps });
2352
+ onProgress?.({ phase: "pass2", segment: i, totalSegments: n, status: "running", totalSteps });
2342
2353
  let pass2 = null;
2343
2354
  const pass2Attempt = await withRetry(
2344
2355
  () => rateLimiter.execute(
@@ -2356,16 +2367,17 @@ async function runPipeline(config) {
2356
2367
  `segment ${i} pass2`
2357
2368
  );
2358
2369
  if (pass2Attempt.error !== null) {
2359
- log8.warn(pass2Attempt.error);
2370
+ log6.warn(pass2Attempt.error);
2360
2371
  errors.push(pass2Attempt.error);
2361
2372
  } else {
2362
2373
  pass2 = pass2Attempt.result;
2363
2374
  pass2RanOnce = true;
2364
2375
  }
2365
- onProgress?.({ phase: "pass2", segment: i, totalSegments: n, status: "done" });
2376
+ currentStep++;
2377
+ onProgress?.({ phase: "pass2", segment: i, totalSegments: n, status: "done", currentStep, totalSteps });
2366
2378
  let pass3c;
2367
2379
  if (strategy.passes.includes("chat")) {
2368
- onProgress?.({ phase: "pass3c", segment: i, totalSegments: n, status: "running" });
2380
+ onProgress?.({ phase: "pass3c", segment: i, totalSegments: n, status: "running", totalSteps });
2369
2381
  const pass3cAttempt = await withRetry(
2370
2382
  () => rateLimiter.execute(
2371
2383
  () => runChatExtraction({
@@ -2382,18 +2394,19 @@ async function runPipeline(config) {
2382
2394
  `segment ${i} pass3c`
2383
2395
  );
2384
2396
  if (pass3cAttempt.error !== null) {
2385
- log8.warn(pass3cAttempt.error);
2397
+ log6.warn(pass3cAttempt.error);
2386
2398
  errors.push(pass3cAttempt.error);
2387
2399
  pass3c = null;
2388
2400
  } else {
2389
2401
  pass3c = pass3cAttempt.result;
2390
2402
  pass3cRanOnce = true;
2391
2403
  }
2392
- onProgress?.({ phase: "pass3c", segment: i, totalSegments: n, status: "done" });
2404
+ currentStep++;
2405
+ onProgress?.({ phase: "pass3c", segment: i, totalSegments: n, status: "done", currentStep, totalSteps });
2393
2406
  }
2394
2407
  let pass3d;
2395
2408
  if (strategy.passes.includes("implicit")) {
2396
- onProgress?.({ phase: "pass3d", segment: i, totalSegments: n, status: "running" });
2409
+ onProgress?.({ phase: "pass3d", segment: i, totalSegments: n, status: "running", totalSteps });
2397
2410
  const pass3dAttempt = await withRetry(
2398
2411
  () => rateLimiter.execute(
2399
2412
  () => runImplicitSignals({
@@ -2411,14 +2424,15 @@ async function runPipeline(config) {
2411
2424
  `segment ${i} pass3d`
2412
2425
  );
2413
2426
  if (pass3dAttempt.error !== null) {
2414
- log8.warn(pass3dAttempt.error);
2427
+ log6.warn(pass3dAttempt.error);
2415
2428
  errors.push(pass3dAttempt.error);
2416
2429
  pass3d = null;
2417
2430
  } else {
2418
2431
  pass3d = pass3dAttempt.result;
2419
2432
  pass3dRanOnce = true;
2420
2433
  }
2421
- onProgress?.({ phase: "pass3d", segment: i, totalSegments: n, status: "done" });
2434
+ currentStep++;
2435
+ onProgress?.({ phase: "pass3d", segment: i, totalSegments: n, status: "done", currentStep, totalSteps });
2422
2436
  }
2423
2437
  results.push({ index: segment.index, pass1, pass2, pass3c, pass3d });
2424
2438
  }
@@ -2447,7 +2461,7 @@ async function runPipeline(config) {
2447
2461
  const pass2Results = results.map((r) => r.pass2);
2448
2462
  let peopleExtraction = null;
2449
2463
  if (strategy.passes.includes("people")) {
2450
- onProgress?.({ phase: "pass3b", segment: 0, totalSegments: 1, status: "running" });
2464
+ onProgress?.({ phase: "pass3b", segment: 0, totalSegments: 1, status: "running", totalSteps });
2451
2465
  const pass3bAttempt = await withRetry(
2452
2466
  () => rateLimiter.execute(
2453
2467
  () => runPeopleExtraction({
@@ -2462,12 +2476,13 @@ async function runPipeline(config) {
2462
2476
  "pass3b"
2463
2477
  );
2464
2478
  if (pass3bAttempt.error !== null) {
2465
- log8.warn(pass3bAttempt.error);
2479
+ log6.warn(pass3bAttempt.error);
2466
2480
  errors.push(pass3bAttempt.error);
2467
2481
  } else {
2468
2482
  peopleExtraction = pass3bAttempt.result;
2469
2483
  }
2470
- onProgress?.({ phase: "pass3b", segment: 0, totalSegments: 1, status: "done" });
2484
+ currentStep++;
2485
+ onProgress?.({ phase: "pass3b", segment: 0, totalSegments: 1, status: "done", currentStep, totalSteps });
2471
2486
  if (peopleExtraction !== null) passesRun.push("pass3b");
2472
2487
  }
2473
2488
  let codeReconstruction = null;
@@ -2491,13 +2506,14 @@ async function runPipeline(config) {
2491
2506
  ),
2492
2507
  pass2Results,
2493
2508
  onProgress: (run, total) => {
2494
- onProgress?.({ phase: "pass3a", segment: run - 1, totalSegments: total, status: "running" });
2509
+ onProgress?.({ phase: "pass3a", segment: run - 1, totalSegments: total, status: "running", totalSteps });
2510
+ currentStep++;
2511
+ onProgress?.({ phase: "pass3a", segment: run - 1, totalSegments: total, status: "done", currentStep, totalSteps });
2495
2512
  }
2496
2513
  });
2497
- onProgress?.({ phase: "pass3a", segment: consensusConfig.runs - 1, totalSegments: consensusConfig.runs, status: "done" });
2498
2514
  if (consensusResult.runsCompleted === 0) {
2499
2515
  const errMsg = "pass3a: all consensus runs failed";
2500
- log8.warn(errMsg);
2516
+ log6.warn(errMsg);
2501
2517
  errors.push(errMsg);
2502
2518
  } else {
2503
2519
  const validationResult = validateCodeReconstruction({
@@ -2513,13 +2529,12 @@ async function runPipeline(config) {
2513
2529
  };
2514
2530
  uncertainCodeFiles = validationResult.uncertain.map((f) => f.filename);
2515
2531
  }
2516
- log8.info(`Code: ${validationResult.confirmed.length} confirmed, ${validationResult.uncertain.length} uncertain, ${validationResult.rejected.length} rejected`);
2517
2532
  }
2518
2533
  if (codeReconstruction !== null) passesRun.push("pass3a");
2519
2534
  }
2520
2535
  let synthesisResult;
2521
2536
  if (strategy.passes.includes("synthesis")) {
2522
- onProgress?.({ phase: "synthesis", segment: 0, totalSegments: 1, status: "running" });
2537
+ onProgress?.({ phase: "synthesis", segment: 0, totalSegments: 1, status: "running", totalSteps });
2523
2538
  const synthAttempt = await withRetry(
2524
2539
  () => rateLimiter.execute(
2525
2540
  () => runSynthesis({
@@ -2536,12 +2551,13 @@ async function runPipeline(config) {
2536
2551
  "synthesis"
2537
2552
  );
2538
2553
  if (synthAttempt.error !== null) {
2539
- log8.warn(synthAttempt.error);
2554
+ log6.warn(synthAttempt.error);
2540
2555
  errors.push(synthAttempt.error);
2541
2556
  } else {
2542
2557
  synthesisResult = synthAttempt.result ?? void 0;
2543
2558
  }
2544
- onProgress?.({ phase: "synthesis", segment: 0, totalSegments: 1, status: "done" });
2559
+ currentStep++;
2560
+ onProgress?.({ phase: "synthesis", segment: 0, totalSegments: 1, status: "done", currentStep, totalSteps });
2545
2561
  if (synthesisResult !== void 0) passesRun.push("synthesis");
2546
2562
  }
2547
2563
  return {
@@ -2742,8 +2758,8 @@ function renderCodeEvent(block) {
2742
2758
  lines.push("```");
2743
2759
  return lines.join("\n");
2744
2760
  }
2745
- function renderVisualEvent(note) {
2746
- return `_[${note.timestamp}]_ **${note.visual_type}:** ${note.description}`;
2761
+ function renderVisualEvent(note2) {
2762
+ return `_[${note2.timestamp}]_ **${note2.visual_type}:** ${note2.description}`;
2747
2763
  }
2748
2764
  function renderEvent(event) {
2749
2765
  switch (event.kind) {
@@ -2774,8 +2790,8 @@ function writeCombined(params) {
2774
2790
  for (const block of pass2.code_blocks) {
2775
2791
  events.push({ timestamp: block.timestamp, kind: "code", segmentIndex: seg.index, data: block });
2776
2792
  }
2777
- for (const note of pass2.visual_notes) {
2778
- events.push({ timestamp: note.timestamp, kind: "visual", segmentIndex: seg.index, data: note });
2793
+ for (const note2 of pass2.visual_notes) {
2794
+ events.push({ timestamp: note2.timestamp, kind: "visual", segmentIndex: seg.index, data: note2 });
2779
2795
  }
2780
2796
  }
2781
2797
  if (events.length === 0) {
@@ -3521,21 +3537,31 @@ async function generateOutput(params) {
3521
3537
  }
3522
3538
 
3523
3539
  // src/core/shutdown.ts
3524
- import { log as log9 } from "@clack/prompts";
3540
+ import { log as log7 } from "@clack/prompts";
3525
3541
  function createShutdownHandler(params) {
3526
3542
  const { client, uploadedFileNames } = params;
3527
3543
  let shuttingDown = false;
3528
3544
  let handler = null;
3545
+ let forceHandler = null;
3546
+ let progressCurrentStep = 0;
3547
+ let progressTotalSteps = 0;
3548
+ let hasProgress = false;
3529
3549
  const sigintHandler = () => {
3530
3550
  if (shuttingDown) {
3531
3551
  process.exit(1);
3532
3552
  return;
3533
3553
  }
3534
3554
  shuttingDown = true;
3535
- log9.warn("Interrupted. Saving partial results...");
3555
+ if (hasProgress) {
3556
+ log7.warn(`Interrupted \u2014 progress saved (${progressCurrentStep}/${progressTotalSteps} steps)`);
3557
+ log7.info(`Resume: vidistill ${params.source} -o ${params.outputDir}/`);
3558
+ } else {
3559
+ log7.warn("Interrupted");
3560
+ }
3536
3561
  const forceExitHandler = () => {
3537
3562
  process.exit(1);
3538
3563
  };
3564
+ forceHandler = forceExitHandler;
3539
3565
  process.once("SIGINT", forceExitHandler);
3540
3566
  const cleanupAndExit = async () => {
3541
3567
  for (const fileName of uploadedFileNames) {
@@ -3564,21 +3590,168 @@ function createShutdownHandler(params) {
3564
3590
  process.removeListener("SIGINT", handler);
3565
3591
  handler = null;
3566
3592
  }
3593
+ if (forceHandler !== null) {
3594
+ process.removeListener("SIGINT", forceHandler);
3595
+ forceHandler = null;
3596
+ }
3597
+ },
3598
+ setProgress(currentStep, totalSteps) {
3599
+ progressCurrentStep = currentStep;
3600
+ progressTotalSteps = totalSteps;
3601
+ hasProgress = true;
3567
3602
  }
3568
3603
  };
3569
3604
  }
3570
3605
 
3606
+ // src/commands/distill.ts
3607
+ async function runDistill(args) {
3608
+ const apiKey = await resolveApiKey();
3609
+ let rawInput = args.input ?? await promptVideoSource();
3610
+ let context = args.context ?? await promptContext();
3611
+ const allFlagsProvided = args.input != null && args.context != null;
3612
+ if (!allFlagsProvided) {
3613
+ let confirmed = false;
3614
+ while (!confirmed) {
3615
+ showConfigBox({ input: rawInput, context, output: args.output });
3616
+ const choice = await promptConfirmation();
3617
+ switch (choice) {
3618
+ case "start":
3619
+ confirmed = true;
3620
+ break;
3621
+ case "edit-video":
3622
+ rawInput = await promptVideoSource();
3623
+ break;
3624
+ case "edit-context":
3625
+ context = await promptContext();
3626
+ break;
3627
+ case "cancel":
3628
+ cancel2("Cancelled.");
3629
+ process.exit(0);
3630
+ }
3631
+ }
3632
+ }
3633
+ const resolved = resolveInput(rawInput);
3634
+ const client = new GeminiClient(apiKey);
3635
+ let fileUri;
3636
+ let mimeType;
3637
+ let duration;
3638
+ let videoTitle;
3639
+ let uploadedFileNames = [];
3640
+ if (resolved.type === "youtube") {
3641
+ const result = await handleYouTube(resolved.value, client);
3642
+ fileUri = result.fileUri;
3643
+ mimeType = result.mimeType;
3644
+ duration = await detectDuration({
3645
+ ytDlpDuration: result.duration,
3646
+ geminiDuration: result.duration
3647
+ });
3648
+ if (result.uploadedFileName != null) {
3649
+ uploadedFileNames = [result.uploadedFileName];
3650
+ }
3651
+ const videoId = extractVideoId(resolved.value);
3652
+ videoTitle = videoId != null ? `youtube-${videoId}` : resolved.value;
3653
+ } else {
3654
+ const result = await handleLocalFile(resolved.value, client);
3655
+ fileUri = result.fileUri;
3656
+ mimeType = result.mimeType;
3657
+ duration = await detectDuration({
3658
+ filePath: resolved.value,
3659
+ geminiDuration: result.duration
3660
+ });
3661
+ if (result.uploadedFileName != null) {
3662
+ uploadedFileNames = [result.uploadedFileName];
3663
+ }
3664
+ videoTitle = basename3(resolved.value, extname2(resolved.value));
3665
+ }
3666
+ const model = MODELS.flash;
3667
+ const outputDir = resolve(args.output);
3668
+ const slug = slugify(videoTitle);
3669
+ const finalOutputDir = `${outputDir}/${slug}`;
3670
+ const shutdownHandler = createShutdownHandler({
3671
+ client,
3672
+ uploadedFileNames,
3673
+ outputDir,
3674
+ videoTitle,
3675
+ source: rawInput,
3676
+ duration,
3677
+ model
3678
+ });
3679
+ shutdownHandler.register();
3680
+ const rateLimiter = new RateLimiter();
3681
+ const progress2 = createProgressDisplay();
3682
+ const startTime = Date.now();
3683
+ const pipelineResult = await runPipeline({
3684
+ client,
3685
+ fileUri,
3686
+ mimeType,
3687
+ duration,
3688
+ model,
3689
+ context,
3690
+ rateLimiter,
3691
+ onProgress: (status) => {
3692
+ progress2.update(status);
3693
+ if (status.currentStep != null && status.totalSteps != null) {
3694
+ shutdownHandler.setProgress(status.currentStep, status.totalSteps);
3695
+ }
3696
+ },
3697
+ onWait: (delayMs) => progress2.onWait(delayMs),
3698
+ isShuttingDown: () => shutdownHandler.isShuttingDown()
3699
+ });
3700
+ const elapsedMs = Date.now() - startTime;
3701
+ shutdownHandler.deregister();
3702
+ progress2.complete(pipelineResult, elapsedMs);
3703
+ if (pipelineResult.interrupted != null) {
3704
+ return;
3705
+ }
3706
+ const outputResult = await generateOutput({
3707
+ pipelineResult,
3708
+ outputDir,
3709
+ videoTitle,
3710
+ source: rawInput,
3711
+ duration,
3712
+ model,
3713
+ processingTimeMs: elapsedMs
3714
+ });
3715
+ const elapsedSecs = Math.round(elapsedMs / 1e3);
3716
+ const elapsedMins = Math.floor(elapsedSecs / 60);
3717
+ const remainSecs = elapsedSecs % 60;
3718
+ const elapsed = elapsedMins > 0 ? `${elapsedMins}m ${remainSecs}s` : `${remainSecs}s`;
3719
+ log8.success(`Done in ${elapsed}`);
3720
+ log8.info(`Output: ${finalOutputDir}/`);
3721
+ log8.info(pc4.dim("Open guide.md for an overview"));
3722
+ if (pipelineResult.codeReconstruction != null) {
3723
+ log8.info(pc4.dim("Tip: vidistill extract code <input> for code-only extraction next time"));
3724
+ } else if (pipelineResult.peopleExtraction?.participants != null && pipelineResult.peopleExtraction.participants.length > 1) {
3725
+ log8.info(pc4.dim("Tip: vidistill rename-speakers <dir> to assign real names"));
3726
+ } else {
3727
+ log8.info(pc4.dim('Tip: vidistill ask <dir> "your question" to query this video'));
3728
+ }
3729
+ if (outputResult.errors.length > 0) {
3730
+ log8.warn(`Output errors: ${pc4.yellow(String(outputResult.errors.length))}`);
3731
+ for (const err of outputResult.errors) {
3732
+ log8.warn(pc4.dim(` ${err}`));
3733
+ }
3734
+ }
3735
+ }
3736
+
3737
+ // import("../commands/**/*.js") in src/cli/index.ts
3738
+ var globImport_commands_js = __glob({});
3739
+
3571
3740
  // src/cli/index.ts
3741
+ var _require2 = createRequire2(import.meta.url);
3742
+ var { version } = _require2("../package.json");
3572
3743
  var DEFAULT_OUTPUT = "./vidistill-output/";
3744
+ var SUBCOMMANDS = /* @__PURE__ */ new Set(["ask", "search", "extract", "mcp", "watch", "rename-speakers"]);
3573
3745
  var main = defineCommand({
3574
3746
  meta: {
3575
3747
  name: "vidistill",
3576
- description: "Video Intelligence Distiller \u2014 turn video into structured notes"
3748
+ version,
3749
+ description: "Video Intelligence Distiller \u2014 turn video into structured notes\n\nCommands: ask, search, extract, mcp, watch, rename-speakers"
3577
3750
  },
3578
3751
  args: {
3579
3752
  input: {
3580
3753
  type: "positional",
3581
- description: "YouTube URL or local file path",
3754
+ description: "YouTube URL, local file path, or subcommand name",
3582
3755
  required: false
3583
3756
  },
3584
3757
  context: {
@@ -3591,107 +3764,38 @@ var main = defineCommand({
3591
3764
  description: `Output directory for generated notes (default: ${DEFAULT_OUTPUT})`,
3592
3765
  alias: "o",
3593
3766
  default: DEFAULT_OUTPUT
3767
+ },
3768
+ lang: {
3769
+ type: "string",
3770
+ description: "Output language",
3771
+ alias: "l"
3594
3772
  }
3595
3773
  },
3596
3774
  async run({ args }) {
3597
3775
  showLogo();
3598
3776
  showIntro();
3777
+ const name = args.input;
3778
+ if (name != null && SUBCOMMANDS.has(name)) {
3779
+ const mod = await globImport_commands_js(`../commands/${name}.js`);
3780
+ if (typeof mod.run !== "function") {
3781
+ throw new Error(`Subcommand "${name}" does not export a run function`);
3782
+ }
3783
+ await mod.run(process.argv.slice(3));
3784
+ return;
3785
+ }
3599
3786
  try {
3600
- const apiKey = await resolveApiKey();
3601
- const rawInput = args.input ?? await promptVideoSource();
3602
- const context = args.context ?? await promptContext();
3603
- showConfig({ input: rawInput, context, output: args.output });
3604
- const resolved = resolveInput(rawInput);
3605
- const client = new GeminiClient(apiKey);
3606
- let fileUri;
3607
- let mimeType;
3608
- let duration;
3609
- let videoTitle;
3610
- let uploadedFileNames = [];
3611
- if (resolved.type === "youtube") {
3612
- const result = await handleYouTube(resolved.value, client);
3613
- fileUri = result.fileUri;
3614
- mimeType = result.mimeType;
3615
- duration = await detectDuration({
3616
- ytDlpDuration: result.duration,
3617
- geminiDuration: result.duration
3618
- });
3619
- if (result.uploadedFileName != null) {
3620
- uploadedFileNames = [result.uploadedFileName];
3621
- }
3622
- const videoId = extractVideoId(resolved.value);
3623
- videoTitle = videoId != null ? `youtube-${videoId}` : resolved.value;
3624
- } else {
3625
- const result = await handleLocalFile(resolved.value, client);
3626
- fileUri = result.fileUri;
3627
- mimeType = result.mimeType;
3628
- duration = await detectDuration({
3629
- filePath: resolved.value,
3630
- geminiDuration: result.duration
3631
- });
3632
- if (result.uploadedFileName != null) {
3633
- uploadedFileNames = [result.uploadedFileName];
3634
- }
3635
- videoTitle = basename3(resolved.value, extname2(resolved.value));
3636
- }
3637
- const mins = Math.floor(duration / 60);
3638
- const secs = Math.round(duration % 60);
3639
- log10.info(`Duration: ${pc5.cyan(`${mins}m ${secs}s`)} (${Math.round(duration)}s)`);
3640
- const model = MODELS.flash;
3641
- const outputDir = resolve(args.output);
3642
- const slug = slugify(videoTitle);
3643
- const finalOutputDir = `${outputDir}/${slug}`;
3644
- const shutdownHandler = createShutdownHandler({
3645
- client,
3646
- uploadedFileNames,
3647
- outputDir,
3648
- videoTitle,
3649
- source: rawInput,
3650
- duration,
3651
- model
3652
- });
3653
- shutdownHandler.register();
3654
- const rateLimiter = new RateLimiter();
3655
- const progress = createProgressDisplay();
3656
- const startTime = Date.now();
3657
- const pipelineResult = await runPipeline({
3658
- client,
3659
- fileUri,
3660
- mimeType,
3661
- duration,
3662
- model,
3663
- context,
3664
- rateLimiter,
3665
- onProgress: (status) => progress.update(status),
3666
- onWait: (delayMs) => progress.onWait(delayMs),
3667
- isShuttingDown: () => shutdownHandler.isShuttingDown()
3787
+ await runDistill({
3788
+ input: args.input,
3789
+ context: args.context,
3790
+ output: args.output,
3791
+ lang: args.lang
3668
3792
  });
3669
- const elapsedMs = Date.now() - startTime;
3670
- shutdownHandler.deregister();
3671
- progress.complete(pipelineResult, elapsedMs);
3672
- const outputResult = await generateOutput({
3673
- pipelineResult,
3674
- outputDir,
3675
- videoTitle,
3676
- source: rawInput,
3677
- duration,
3678
- model,
3679
- processingTimeMs: elapsedMs
3680
- });
3681
- const fileCount = outputResult.filesGenerated.length;
3682
- log10.success(
3683
- `Output: ${pc5.cyan(finalOutputDir + "/")} \u2014 ${pc5.cyan(String(fileCount))} files generated ${pc5.dim("(guide.md for overview)")}`
3684
- );
3685
- if (outputResult.errors.length > 0) {
3686
- log10.warn(`Output errors: ${pc5.yellow(String(outputResult.errors.length))}`);
3687
- for (const err of outputResult.errors) {
3688
- log10.warn(pc5.dim(` ${err}`));
3689
- }
3690
- }
3691
3793
  } catch (err) {
3794
+ const { log: log9 } = await import("@clack/prompts");
3795
+ const { default: pc5 } = await import("picocolors");
3692
3796
  const raw = err instanceof Error ? err.message : String(err);
3693
3797
  const message = raw.split("\n")[0].slice(0, 200);
3694
- log10.error(pc5.red(message));
3798
+ log9.error(pc5.red(message));
3695
3799
  process.exit(1);
3696
3800
  }
3697
3801
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vidistill",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Video intelligence distiller — extract structured notes, transcripts, and insights from any video using Gemini",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@clack/prompts": "^0.9.1",
32
+ "@clack/prompts": "1.0.1",
33
33
  "@google/genai": "^1.40.0",
34
34
  "citty": "^0.1.6",
35
35
  "figlet": "^1.8.0",