vidistill 0.2.0 → 0.2.1

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