@steipete/summarize 0.8.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -1
- package/LICENSE +1 -1
- package/README.md +309 -182
- package/dist/cli.js +1 -1
- package/dist/esm/cache.js +72 -4
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/config.js +197 -1
- package/dist/esm/config.js.map +1 -1
- package/dist/esm/content/asset.js +75 -2
- package/dist/esm/content/asset.js.map +1 -1
- package/dist/esm/daemon/agent.js +547 -0
- package/dist/esm/daemon/agent.js.map +1 -0
- package/dist/esm/daemon/chat.js +97 -0
- package/dist/esm/daemon/chat.js.map +1 -0
- package/dist/esm/daemon/cli.js +105 -10
- package/dist/esm/daemon/cli.js.map +1 -1
- package/dist/esm/daemon/env-snapshot.js +3 -0
- package/dist/esm/daemon/env-snapshot.js.map +1 -1
- package/dist/esm/daemon/flow-context.js +53 -28
- package/dist/esm/daemon/flow-context.js.map +1 -1
- package/dist/esm/daemon/launchd.js +27 -0
- package/dist/esm/daemon/launchd.js.map +1 -1
- package/dist/esm/daemon/process-registry.js +206 -0
- package/dist/esm/daemon/process-registry.js.map +1 -0
- package/dist/esm/daemon/schtasks.js +64 -0
- package/dist/esm/daemon/schtasks.js.map +1 -1
- package/dist/esm/daemon/server.js +1034 -52
- package/dist/esm/daemon/server.js.map +1 -1
- package/dist/esm/daemon/summarize.js +66 -18
- package/dist/esm/daemon/summarize.js.map +1 -1
- package/dist/esm/daemon/systemd.js +61 -0
- package/dist/esm/daemon/systemd.js.map +1 -1
- package/dist/esm/flags.js +24 -0
- package/dist/esm/flags.js.map +1 -1
- package/dist/esm/llm/attachments.js +2 -0
- package/dist/esm/llm/attachments.js.map +1 -0
- package/dist/esm/llm/errors.js +6 -0
- package/dist/esm/llm/errors.js.map +1 -0
- package/dist/esm/llm/generate-text.js +206 -356
- package/dist/esm/llm/generate-text.js.map +1 -1
- package/dist/esm/llm/html-to-markdown.js +1 -2
- package/dist/esm/llm/html-to-markdown.js.map +1 -1
- package/dist/esm/llm/prompt.js.map +1 -1
- package/dist/esm/llm/providers/anthropic.js +126 -0
- package/dist/esm/llm/providers/anthropic.js.map +1 -0
- package/dist/esm/llm/providers/google.js +78 -0
- package/dist/esm/llm/providers/google.js.map +1 -0
- package/dist/esm/llm/providers/models.js +111 -0
- package/dist/esm/llm/providers/models.js.map +1 -0
- package/dist/esm/llm/providers/openai.js +150 -0
- package/dist/esm/llm/providers/openai.js.map +1 -0
- package/dist/esm/llm/providers/shared.js +48 -0
- package/dist/esm/llm/providers/shared.js.map +1 -0
- package/dist/esm/llm/providers/types.js +2 -0
- package/dist/esm/llm/providers/types.js.map +1 -0
- package/dist/esm/llm/transcript-to-markdown.js +1 -2
- package/dist/esm/llm/transcript-to-markdown.js.map +1 -1
- package/dist/esm/llm/types.js +2 -0
- package/dist/esm/llm/types.js.map +1 -0
- package/dist/esm/llm/usage.js +69 -0
- package/dist/esm/llm/usage.js.map +1 -0
- package/dist/esm/logging/daemon.js +124 -0
- package/dist/esm/logging/daemon.js.map +1 -0
- package/dist/esm/logging/ring-file.js +66 -0
- package/dist/esm/logging/ring-file.js.map +1 -0
- package/dist/esm/media-cache.js +251 -0
- package/dist/esm/media-cache.js.map +1 -0
- package/dist/esm/model-auto.js +103 -5
- package/dist/esm/model-auto.js.map +1 -1
- package/dist/esm/processes.js +2 -0
- package/dist/esm/processes.js.map +1 -0
- package/dist/esm/refresh-free.js +3 -3
- package/dist/esm/refresh-free.js.map +1 -1
- package/dist/esm/run/attachments.js +8 -4
- package/dist/esm/run/attachments.js.map +1 -1
- package/dist/esm/run/bird.js +118 -5
- package/dist/esm/run/bird.js.map +1 -1
- package/dist/esm/run/cache-state.js +3 -2
- package/dist/esm/run/cache-state.js.map +1 -1
- package/dist/esm/run/cli-preflight.js +19 -1
- package/dist/esm/run/cli-preflight.js.map +1 -1
- package/dist/esm/run/constants.js +0 -7
- package/dist/esm/run/constants.js.map +1 -1
- package/dist/esm/run/finish-line.js +58 -11
- package/dist/esm/run/finish-line.js.map +1 -1
- package/dist/esm/run/flows/asset/extract.js +70 -0
- package/dist/esm/run/flows/asset/extract.js.map +1 -0
- package/dist/esm/run/flows/asset/input.js +209 -25
- package/dist/esm/run/flows/asset/input.js.map +1 -1
- package/dist/esm/run/flows/asset/media-policy.js +3 -0
- package/dist/esm/run/flows/asset/media-policy.js.map +1 -0
- package/dist/esm/run/flows/asset/media.js +224 -0
- package/dist/esm/run/flows/asset/media.js.map +1 -0
- package/dist/esm/run/flows/asset/output.js +98 -0
- package/dist/esm/run/flows/asset/output.js.map +1 -0
- package/dist/esm/run/flows/asset/preprocess.js +92 -16
- package/dist/esm/run/flows/asset/preprocess.js.map +1 -1
- package/dist/esm/run/flows/asset/summary.js +165 -11
- package/dist/esm/run/flows/asset/summary.js.map +1 -1
- package/dist/esm/run/flows/url/extract.js +6 -6
- package/dist/esm/run/flows/url/extract.js.map +1 -1
- package/dist/esm/run/flows/url/flow.js +338 -36
- package/dist/esm/run/flows/url/flow.js.map +1 -1
- package/dist/esm/run/flows/url/markdown.js +6 -1
- package/dist/esm/run/flows/url/markdown.js.map +1 -1
- package/dist/esm/run/flows/url/slides-output.js +485 -0
- package/dist/esm/run/flows/url/slides-output.js.map +1 -0
- package/dist/esm/run/flows/url/slides-text.js +628 -0
- package/dist/esm/run/flows/url/slides-text.js.map +1 -0
- package/dist/esm/run/flows/url/summary.js +358 -83
- package/dist/esm/run/flows/url/summary.js.map +1 -1
- package/dist/esm/run/help.js +94 -5
- package/dist/esm/run/help.js.map +1 -1
- package/dist/esm/run/logging.js +12 -4
- package/dist/esm/run/logging.js.map +1 -1
- package/dist/esm/run/media-cache-state.js +33 -0
- package/dist/esm/run/media-cache-state.js.map +1 -0
- package/dist/esm/run/progress.js +19 -1
- package/dist/esm/run/progress.js.map +1 -1
- package/dist/esm/run/run-context.js +19 -0
- package/dist/esm/run/run-context.js.map +1 -0
- package/dist/esm/run/run-output.js +1 -1
- package/dist/esm/run/run-output.js.map +1 -1
- package/dist/esm/run/run-settings.js +182 -0
- package/dist/esm/run/run-settings.js.map +1 -0
- package/dist/esm/run/runner.js +225 -32
- package/dist/esm/run/runner.js.map +1 -1
- package/dist/esm/run/slides-cli.js +225 -0
- package/dist/esm/run/slides-cli.js.map +1 -0
- package/dist/esm/run/slides-render.js +163 -0
- package/dist/esm/run/slides-render.js.map +1 -0
- package/dist/esm/run/stream-output.js +63 -0
- package/dist/esm/run/stream-output.js.map +1 -0
- package/dist/esm/run/streaming.js +16 -43
- package/dist/esm/run/streaming.js.map +1 -1
- package/dist/esm/run/summary-engine.js +59 -41
- package/dist/esm/run/summary-engine.js.map +1 -1
- package/dist/esm/run/transcriber-cli.js +148 -0
- package/dist/esm/run/transcriber-cli.js.map +1 -0
- package/dist/esm/shared/sse-events.js +26 -0
- package/dist/esm/shared/sse-events.js.map +1 -0
- package/dist/esm/shared/streaming-merge.js +44 -0
- package/dist/esm/shared/streaming-merge.js.map +1 -0
- package/dist/esm/slides/extract.js +1942 -0
- package/dist/esm/slides/extract.js.map +1 -0
- package/dist/esm/slides/index.js +4 -0
- package/dist/esm/slides/index.js.map +1 -0
- package/dist/esm/slides/settings.js +73 -0
- package/dist/esm/slides/settings.js.map +1 -0
- package/dist/esm/slides/store.js +111 -0
- package/dist/esm/slides/store.js.map +1 -0
- package/dist/esm/slides/types.js +2 -0
- package/dist/esm/slides/types.js.map +1 -0
- package/dist/esm/tty/osc-progress.js +21 -1
- package/dist/esm/tty/osc-progress.js.map +1 -1
- package/dist/esm/tty/progress/fetch-html.js +8 -4
- package/dist/esm/tty/progress/fetch-html.js.map +1 -1
- package/dist/esm/tty/progress/transcript.js +82 -31
- package/dist/esm/tty/progress/transcript.js.map +1 -1
- package/dist/esm/tty/spinner.js +2 -2
- package/dist/esm/tty/spinner.js.map +1 -1
- package/dist/esm/tty/theme.js +189 -0
- package/dist/esm/tty/theme.js.map +1 -0
- package/dist/esm/tty/website-progress.js +17 -13
- package/dist/esm/tty/website-progress.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/esm/version.js.map +1 -1
- package/dist/types/cache.d.ts +14 -2
- package/dist/types/config.d.ts +34 -0
- package/dist/types/daemon/agent.d.ts +25 -0
- package/dist/types/daemon/chat.d.ts +27 -0
- package/dist/types/daemon/env-snapshot.d.ts +1 -1
- package/dist/types/daemon/flow-context.d.ts +24 -3
- package/dist/types/daemon/launchd.d.ts +4 -0
- package/dist/types/daemon/process-registry.d.ts +73 -0
- package/dist/types/daemon/schtasks.d.ts +4 -0
- package/dist/types/daemon/server.d.ts +7 -1
- package/dist/types/daemon/summarize.d.ts +47 -5
- package/dist/types/daemon/systemd.d.ts +4 -0
- package/dist/types/flags.d.ts +1 -0
- package/dist/types/llm/attachments.d.ts +6 -0
- package/dist/types/llm/errors.d.ts +1 -0
- package/dist/types/llm/generate-text.d.ts +29 -13
- package/dist/types/llm/prompt.d.ts +7 -2
- package/dist/types/llm/providers/anthropic.d.ts +30 -0
- package/dist/types/llm/providers/google.d.ts +29 -0
- package/dist/types/llm/providers/models.d.ts +27 -0
- package/dist/types/llm/providers/openai.d.ts +38 -0
- package/dist/types/llm/providers/shared.d.ts +14 -0
- package/dist/types/llm/providers/types.d.ts +6 -0
- package/dist/types/llm/types.d.ts +5 -0
- package/dist/types/llm/usage.d.ts +5 -0
- package/dist/types/logging/daemon.d.ts +26 -0
- package/dist/types/logging/ring-file.d.ts +10 -0
- package/dist/types/media-cache.d.ts +22 -0
- package/dist/types/model-auto.d.ts +1 -0
- package/dist/types/processes.d.ts +1 -0
- package/dist/types/run/attachments.d.ts +9 -6
- package/dist/types/run/bird.d.ts +7 -0
- package/dist/types/run/constants.d.ts +0 -2
- package/dist/types/run/finish-line.d.ts +59 -1
- package/dist/types/run/flows/asset/extract.d.ts +18 -0
- package/dist/types/run/flows/asset/input.d.ts +12 -2
- package/dist/types/run/flows/asset/media-policy.d.ts +2 -0
- package/dist/types/run/flows/asset/media.d.ts +21 -0
- package/dist/types/run/flows/asset/output.d.ts +42 -0
- package/dist/types/run/flows/asset/preprocess.d.ts +22 -2
- package/dist/types/run/flows/asset/summary.d.ts +6 -0
- package/dist/types/run/flows/url/extract.d.ts +2 -1
- package/dist/types/run/flows/url/slides-output.d.ts +66 -0
- package/dist/types/run/flows/url/slides-text.d.ts +87 -0
- package/dist/types/run/flows/url/summary.d.ts +11 -3
- package/dist/types/run/flows/url/types.d.ts +29 -2
- package/dist/types/run/help.d.ts +3 -0
- package/dist/types/run/logging.d.ts +3 -2
- package/dist/types/run/media-cache-state.d.ts +7 -0
- package/dist/types/run/progress.d.ts +2 -1
- package/dist/types/run/run-context.d.ts +44 -0
- package/dist/types/run/run-settings.d.ts +62 -0
- package/dist/types/run/slides-cli.d.ts +9 -0
- package/dist/types/run/slides-render.d.ts +30 -0
- package/dist/types/run/stream-output.d.ts +12 -0
- package/dist/types/run/streaming.d.ts +10 -4
- package/dist/types/run/summary-engine.d.ts +15 -3
- package/dist/types/run/summary-llm.d.ts +2 -2
- package/dist/types/run/transcriber-cli.d.ts +8 -0
- package/dist/types/shared/sse-events.d.ts +64 -0
- package/dist/types/shared/streaming-merge.d.ts +4 -0
- package/dist/types/slides/extract.d.ts +42 -0
- package/dist/types/slides/index.d.ts +5 -0
- package/dist/types/slides/settings.d.ts +20 -0
- package/dist/types/slides/store.d.ts +15 -0
- package/dist/types/slides/types.d.ts +40 -0
- package/dist/types/tty/osc-progress.d.ts +2 -2
- package/dist/types/tty/progress/fetch-html.d.ts +3 -1
- package/dist/types/tty/progress/transcript.d.ts +3 -1
- package/dist/types/tty/spinner.d.ts +3 -1
- package/dist/types/tty/theme.d.ts +44 -0
- package/dist/types/tty/website-progress.d.ts +3 -1
- package/dist/types/version.d.ts +1 -1
- package/docs/README.md +13 -8
- package/docs/_config.yml +26 -0
- package/docs/_layouts/default.html +60 -0
- package/docs/agent.md +333 -0
- package/docs/assets/site.css +748 -0
- package/docs/assets/site.js +72 -0
- package/docs/assets/summarize-cli.png +0 -0
- package/docs/assets/summarize-extension.png +0 -0
- package/docs/assets/youtube-slides.png +0 -0
- package/docs/cache.md +29 -3
- package/docs/chrome-extension.md +85 -7
- package/docs/config.md +74 -2
- package/docs/extract-only.md +10 -2
- package/docs/index.html +205 -0
- package/docs/index.md +25 -0
- package/docs/language.md +1 -1
- package/docs/llm.md +17 -1
- package/docs/manual-tests.md +2 -0
- package/docs/media.md +37 -0
- package/docs/model-auto.md +2 -1
- package/docs/nvidia-onnx-transcription.md +55 -0
- package/docs/openai.md +5 -0
- package/docs/releasing.md +26 -0
- package/docs/site/assets/site.css +399 -228
- package/docs/site/assets/summarize-cli.png +0 -0
- package/docs/site/assets/summarize-extension.png +0 -0
- package/docs/site/docs/chrome-extension.html +89 -0
- package/docs/site/docs/config.html +1 -0
- package/docs/site/docs/extract-only.html +1 -0
- package/docs/site/docs/firecrawl.html +1 -0
- package/docs/site/docs/index.html +5 -0
- package/docs/site/docs/llm.html +1 -0
- package/docs/site/docs/openai.html +1 -0
- package/docs/site/docs/website.html +1 -0
- package/docs/site/docs/youtube.html +1 -0
- package/docs/site/index.html +148 -84
- package/docs/slides.md +74 -0
- package/docs/timestamps.md +103 -0
- package/docs/website.md +13 -0
- package/docs/youtube.md +16 -0
- package/package.json +22 -18
- package/dist/esm/daemon/request-settings.js +0 -91
- package/dist/esm/daemon/request-settings.js.map +0 -1
- package/dist/types/daemon/request-settings.d.ts +0 -27
|
@@ -0,0 +1,1942 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { extractYouTubeVideoId, isDirectMediaUrl, isYouTubeUrl } from '../content/index.js';
|
|
6
|
+
import { spawnTracked } from '../processes.js';
|
|
7
|
+
import { resolveExecutableInPath } from '../run/env.js';
|
|
8
|
+
import { buildSlidesDirId, readSlidesCacheIfValid, resolveSlidesDir, serializeSlideImagePath, } from './store.js';
|
|
9
|
+
const FFMPEG_TIMEOUT_FALLBACK_MS = 300_000;
|
|
10
|
+
const slidesLocks = new Map();
|
|
11
|
+
const YT_DLP_TIMEOUT_MS = 300_000;
|
|
12
|
+
const TESSERACT_TIMEOUT_MS = 120_000;
|
|
13
|
+
const DEFAULT_SLIDES_WORKERS = 8;
|
|
14
|
+
const DEFAULT_SLIDES_SAMPLE_COUNT = 8;
|
|
15
|
+
// Prefer broadly-decodable H.264/MP4 for ffmpeg stability.
|
|
16
|
+
// (Some "bestvideo" picks AV1 which can fail on certain ffmpeg builds / hwaccel setups.)
|
|
17
|
+
const DEFAULT_YT_DLP_FORMAT_EXTRACT = 'bestvideo[height<=720][vcodec^=avc1][ext=mp4]/best[height<=720][vcodec^=avc1][ext=mp4]/bestvideo[height<=720][ext=mp4]/best[height<=720]';
|
|
18
|
+
function createSlidesLogger(logger) {
|
|
19
|
+
const logSlides = (message) => {
|
|
20
|
+
if (!logger)
|
|
21
|
+
return;
|
|
22
|
+
logger(message);
|
|
23
|
+
};
|
|
24
|
+
const logSlidesTiming = (label, startedAt) => {
|
|
25
|
+
const elapsedMs = Date.now() - startedAt;
|
|
26
|
+
logSlides(`${label} elapsedMs=${elapsedMs}`);
|
|
27
|
+
return elapsedMs;
|
|
28
|
+
};
|
|
29
|
+
return { logSlides, logSlidesTiming };
|
|
30
|
+
}
|
|
31
|
+
function resolveSlidesWorkers(env) {
|
|
32
|
+
const raw = env.SUMMARIZE_SLIDES_WORKERS ?? env.SLIDES_WORKERS;
|
|
33
|
+
if (!raw)
|
|
34
|
+
return DEFAULT_SLIDES_WORKERS;
|
|
35
|
+
const parsed = Number(raw);
|
|
36
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
37
|
+
return DEFAULT_SLIDES_WORKERS;
|
|
38
|
+
return Math.max(1, Math.min(16, Math.round(parsed)));
|
|
39
|
+
}
|
|
40
|
+
function resolveSlidesSampleCount(env) {
|
|
41
|
+
const raw = env.SUMMARIZE_SLIDES_SAMPLES ?? env.SLIDES_SAMPLES;
|
|
42
|
+
if (!raw)
|
|
43
|
+
return DEFAULT_SLIDES_SAMPLE_COUNT;
|
|
44
|
+
const parsed = Number(raw);
|
|
45
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
46
|
+
return DEFAULT_SLIDES_SAMPLE_COUNT;
|
|
47
|
+
return Math.max(3, Math.min(12, Math.round(parsed)));
|
|
48
|
+
}
|
|
49
|
+
function resolveSlidesYtDlpExtractFormat(env) {
|
|
50
|
+
return (env.SUMMARIZE_SLIDES_YTDLP_FORMAT_EXTRACT ??
|
|
51
|
+
env.SLIDES_YTDLP_FORMAT_EXTRACT ??
|
|
52
|
+
DEFAULT_YT_DLP_FORMAT_EXTRACT).trim();
|
|
53
|
+
}
|
|
54
|
+
function resolveSlidesStreamFallback(env) {
|
|
55
|
+
const raw = env.SLIDES_EXTRACT_STREAM?.trim().toLowerCase();
|
|
56
|
+
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
57
|
+
}
|
|
58
|
+
function buildSlidesMediaCacheKey(url) {
|
|
59
|
+
return `${url}#summarize-slides`;
|
|
60
|
+
}
|
|
61
|
+
function formatBytes(bytes) {
|
|
62
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
63
|
+
return '0B';
|
|
64
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
65
|
+
let value = bytes;
|
|
66
|
+
let unit = units[0] ?? 'B';
|
|
67
|
+
for (let i = 1; i < units.length && value >= 1024; i += 1) {
|
|
68
|
+
value /= 1024;
|
|
69
|
+
unit = units[i] ?? unit;
|
|
70
|
+
}
|
|
71
|
+
const rounded = value >= 100 ? Math.round(value) : Math.round(value * 10) / 10;
|
|
72
|
+
return `${rounded}${unit}`;
|
|
73
|
+
}
|
|
74
|
+
function resolveToolPath(binary, env, explicitEnvKey) {
|
|
75
|
+
const explicit = explicitEnvKey && typeof env[explicitEnvKey] === 'string' ? env[explicitEnvKey]?.trim() : '';
|
|
76
|
+
if (explicit)
|
|
77
|
+
return resolveExecutableInPath(explicit, env);
|
|
78
|
+
return resolveExecutableInPath(binary, env);
|
|
79
|
+
}
|
|
80
|
+
export function resolveSlideSource({ url, extracted, }) {
|
|
81
|
+
const directUrl = extracted.video?.url ?? extracted.url;
|
|
82
|
+
const youtubeCandidate = extractYouTubeVideoId(extracted.video?.url ?? '') ??
|
|
83
|
+
extractYouTubeVideoId(extracted.url) ??
|
|
84
|
+
extractYouTubeVideoId(url);
|
|
85
|
+
if (youtubeCandidate) {
|
|
86
|
+
return {
|
|
87
|
+
url: `https://www.youtube.com/watch?v=${youtubeCandidate}`,
|
|
88
|
+
kind: 'youtube',
|
|
89
|
+
sourceId: buildYoutubeSourceId(youtubeCandidate),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (extracted.video?.kind === 'direct' || isDirectMediaUrl(directUrl) || isDirectMediaUrl(url)) {
|
|
93
|
+
const normalized = directUrl || url;
|
|
94
|
+
return {
|
|
95
|
+
url: normalized,
|
|
96
|
+
kind: 'direct',
|
|
97
|
+
sourceId: buildDirectSourceId(normalized),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (isYouTubeUrl(url)) {
|
|
101
|
+
const fallbackId = extractYouTubeVideoId(url);
|
|
102
|
+
if (fallbackId) {
|
|
103
|
+
return {
|
|
104
|
+
url: `https://www.youtube.com/watch?v=${fallbackId}`,
|
|
105
|
+
kind: 'youtube',
|
|
106
|
+
sourceId: buildYoutubeSourceId(fallbackId),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
export function resolveSlideSourceFromUrl(url) {
|
|
113
|
+
const youtubeCandidate = extractYouTubeVideoId(url);
|
|
114
|
+
if (youtubeCandidate) {
|
|
115
|
+
return {
|
|
116
|
+
url: `https://www.youtube.com/watch?v=${youtubeCandidate}`,
|
|
117
|
+
kind: 'youtube',
|
|
118
|
+
sourceId: buildYoutubeSourceId(youtubeCandidate),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (isDirectMediaUrl(url)) {
|
|
122
|
+
return {
|
|
123
|
+
url,
|
|
124
|
+
kind: 'direct',
|
|
125
|
+
sourceId: buildDirectSourceId(url),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (isYouTubeUrl(url)) {
|
|
129
|
+
const fallbackId = extractYouTubeVideoId(url);
|
|
130
|
+
if (fallbackId) {
|
|
131
|
+
return {
|
|
132
|
+
url: `https://www.youtube.com/watch?v=${fallbackId}`,
|
|
133
|
+
kind: 'youtube',
|
|
134
|
+
sourceId: buildYoutubeSourceId(fallbackId),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
export async function extractSlidesForSource({ source, settings, noCache = false, mediaCache = null, env, timeoutMs, ytDlpPath, ffmpegPath, tesseractPath, hooks, }) {
|
|
141
|
+
const slidesDir = resolveSlidesDir(settings.outputDir, source.sourceId);
|
|
142
|
+
return withSlidesLock(slidesDir, async () => {
|
|
143
|
+
const { logSlides, logSlidesTiming } = createSlidesLogger(hooks?.onSlidesLog ?? null);
|
|
144
|
+
if (!noCache) {
|
|
145
|
+
const cached = await readSlidesCacheIfValid({ source, settings });
|
|
146
|
+
if (cached) {
|
|
147
|
+
hooks?.onSlidesTimeline?.(cached);
|
|
148
|
+
return cached;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const reportSlidesProgress = (() => {
|
|
152
|
+
const onSlidesProgress = hooks?.onSlidesProgress;
|
|
153
|
+
if (!onSlidesProgress)
|
|
154
|
+
return null;
|
|
155
|
+
let lastText = '';
|
|
156
|
+
let lastPercent = 0;
|
|
157
|
+
return (label, percent, detail) => {
|
|
158
|
+
const clamped = clamp(Math.round(percent), 0, 100);
|
|
159
|
+
const nextPercent = Math.max(lastPercent, clamped);
|
|
160
|
+
const suffix = detail ? ` ${detail}` : '';
|
|
161
|
+
const text = `Slides: ${label}${suffix} ${nextPercent}%`;
|
|
162
|
+
if (text === lastText)
|
|
163
|
+
return;
|
|
164
|
+
lastText = text;
|
|
165
|
+
lastPercent = nextPercent;
|
|
166
|
+
onSlidesProgress(text);
|
|
167
|
+
};
|
|
168
|
+
})();
|
|
169
|
+
const warnings = [];
|
|
170
|
+
const workers = resolveSlidesWorkers(env);
|
|
171
|
+
const totalStartedAt = Date.now();
|
|
172
|
+
logSlides(`pipeline=ingest(sequential)->scene-detect(parallel:${workers})->extract-frames(parallel:${workers})->ocr(parallel:${workers})`);
|
|
173
|
+
const ffmpegBinary = ffmpegPath ?? resolveToolPath('ffmpeg', env, 'FFMPEG_PATH');
|
|
174
|
+
if (!ffmpegBinary) {
|
|
175
|
+
throw new Error('Missing ffmpeg (install ffmpeg or add it to PATH).');
|
|
176
|
+
}
|
|
177
|
+
const ffprobeBinary = resolveToolPath('ffprobe', env, 'FFPROBE_PATH');
|
|
178
|
+
if (settings.ocr && !tesseractPath) {
|
|
179
|
+
const resolved = resolveToolPath('tesseract', env, 'TESSERACT_PATH');
|
|
180
|
+
if (!resolved) {
|
|
181
|
+
throw new Error('Missing tesseract OCR (install tesseract or skip --slides-ocr).');
|
|
182
|
+
}
|
|
183
|
+
tesseractPath = resolved;
|
|
184
|
+
}
|
|
185
|
+
const ocrEnabled = Boolean(settings.ocr && tesseractPath);
|
|
186
|
+
const ocrAvailable = Boolean(tesseractPath ?? resolveToolPath('tesseract', env, 'TESSERACT_PATH'));
|
|
187
|
+
const P_PREPARE = 2;
|
|
188
|
+
const P_FETCH_VIDEO = 6;
|
|
189
|
+
const P_DOWNLOAD_VIDEO = 35;
|
|
190
|
+
const P_DETECT_SCENES = 60;
|
|
191
|
+
const P_EXTRACT_FRAMES = 90;
|
|
192
|
+
const P_OCR = 99;
|
|
193
|
+
const P_FINAL = 100;
|
|
194
|
+
{
|
|
195
|
+
const prepareStartedAt = Date.now();
|
|
196
|
+
await prepareSlidesDir(slidesDir);
|
|
197
|
+
logSlidesTiming('prepare output dir', prepareStartedAt);
|
|
198
|
+
}
|
|
199
|
+
reportSlidesProgress?.('preparing source', P_PREPARE);
|
|
200
|
+
const allowStreamFallback = resolveSlidesStreamFallback(env);
|
|
201
|
+
let inputPath = source.url;
|
|
202
|
+
let inputCleanup = null;
|
|
203
|
+
const mediaCacheKey = mediaCache ? buildSlidesMediaCacheKey(source.url) : null;
|
|
204
|
+
const cachedMedia = mediaCacheKey ? await mediaCache?.get({ url: mediaCacheKey }) : null;
|
|
205
|
+
if (cachedMedia) {
|
|
206
|
+
inputPath = cachedMedia.filePath;
|
|
207
|
+
const detail = typeof cachedMedia.sizeBytes === 'number'
|
|
208
|
+
? `(${formatBytes(cachedMedia.sizeBytes)})`
|
|
209
|
+
: undefined;
|
|
210
|
+
reportSlidesProgress?.('using cached video', P_DOWNLOAD_VIDEO, detail);
|
|
211
|
+
}
|
|
212
|
+
else if (source.kind === 'youtube') {
|
|
213
|
+
if (!ytDlpPath) {
|
|
214
|
+
throw new Error('Slides for YouTube require yt-dlp (set YT_DLP_PATH or install yt-dlp).');
|
|
215
|
+
}
|
|
216
|
+
const ytDlp = ytDlpPath;
|
|
217
|
+
const format = resolveSlidesYtDlpExtractFormat(env);
|
|
218
|
+
reportSlidesProgress?.('downloading video', P_FETCH_VIDEO);
|
|
219
|
+
const downloadStartedAt = Date.now();
|
|
220
|
+
try {
|
|
221
|
+
const downloaded = await downloadYoutubeVideo({
|
|
222
|
+
ytDlpPath: ytDlp,
|
|
223
|
+
url: source.url,
|
|
224
|
+
timeoutMs,
|
|
225
|
+
format,
|
|
226
|
+
onProgress: (percent, detail) => {
|
|
227
|
+
const ratio = clamp(percent / 100, 0, 1);
|
|
228
|
+
const mapped = P_FETCH_VIDEO + ratio * (P_DOWNLOAD_VIDEO - P_FETCH_VIDEO);
|
|
229
|
+
reportSlidesProgress?.('downloading video', mapped, detail);
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
const cached = mediaCacheKey
|
|
233
|
+
? await mediaCache?.put({
|
|
234
|
+
url: mediaCacheKey,
|
|
235
|
+
filePath: downloaded.filePath,
|
|
236
|
+
filename: path.basename(downloaded.filePath),
|
|
237
|
+
})
|
|
238
|
+
: null;
|
|
239
|
+
inputPath = cached?.filePath ?? downloaded.filePath;
|
|
240
|
+
inputCleanup = downloaded.cleanup;
|
|
241
|
+
logSlidesTiming(`yt-dlp download (detect+extract, format=${format})`, downloadStartedAt);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (!allowStreamFallback) {
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
warnings.push(`Failed to download video; falling back to stream URL: ${String(error)}`);
|
|
248
|
+
reportSlidesProgress?.('fetching video', P_FETCH_VIDEO);
|
|
249
|
+
const streamStartedAt = Date.now();
|
|
250
|
+
const streamUrl = await resolveYoutubeStreamUrl({
|
|
251
|
+
ytDlpPath: ytDlp,
|
|
252
|
+
url: source.url,
|
|
253
|
+
format,
|
|
254
|
+
timeoutMs,
|
|
255
|
+
});
|
|
256
|
+
inputPath = streamUrl;
|
|
257
|
+
logSlidesTiming(`yt-dlp stream url (detect+extract, format=${format})`, streamStartedAt);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else if (source.kind === 'direct') {
|
|
261
|
+
const shouldUseYtDlp = !isDirectMediaUrl(source.url);
|
|
262
|
+
if (shouldUseYtDlp) {
|
|
263
|
+
if (!ytDlpPath) {
|
|
264
|
+
throw new Error('Slides for remote videos require yt-dlp (set YT_DLP_PATH or install yt-dlp).');
|
|
265
|
+
}
|
|
266
|
+
const ytDlp = ytDlpPath;
|
|
267
|
+
const format = resolveSlidesYtDlpExtractFormat(env);
|
|
268
|
+
reportSlidesProgress?.('downloading video', P_FETCH_VIDEO);
|
|
269
|
+
const downloadStartedAt = Date.now();
|
|
270
|
+
try {
|
|
271
|
+
const downloaded = await downloadYoutubeVideo({
|
|
272
|
+
ytDlpPath: ytDlp,
|
|
273
|
+
url: source.url,
|
|
274
|
+
timeoutMs,
|
|
275
|
+
format,
|
|
276
|
+
onProgress: (percent, detail) => {
|
|
277
|
+
const ratio = clamp(percent / 100, 0, 1);
|
|
278
|
+
const mapped = P_FETCH_VIDEO + ratio * (P_DOWNLOAD_VIDEO - P_FETCH_VIDEO);
|
|
279
|
+
reportSlidesProgress?.('downloading video', mapped, detail);
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
const cached = mediaCacheKey
|
|
283
|
+
? await mediaCache?.put({
|
|
284
|
+
url: mediaCacheKey,
|
|
285
|
+
filePath: downloaded.filePath,
|
|
286
|
+
filename: path.basename(downloaded.filePath),
|
|
287
|
+
})
|
|
288
|
+
: null;
|
|
289
|
+
inputPath = cached?.filePath ?? downloaded.filePath;
|
|
290
|
+
inputCleanup = downloaded.cleanup;
|
|
291
|
+
logSlidesTiming(`yt-dlp download (direct source, format=${format})`, downloadStartedAt);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (!allowStreamFallback) {
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
warnings.push(`Failed to download video; falling back to stream URL: ${String(error)}`);
|
|
298
|
+
reportSlidesProgress?.('fetching video', P_FETCH_VIDEO);
|
|
299
|
+
const streamStartedAt = Date.now();
|
|
300
|
+
const streamUrl = await resolveYoutubeStreamUrl({
|
|
301
|
+
ytDlpPath: ytDlp,
|
|
302
|
+
url: source.url,
|
|
303
|
+
format,
|
|
304
|
+
timeoutMs,
|
|
305
|
+
});
|
|
306
|
+
inputPath = streamUrl;
|
|
307
|
+
logSlidesTiming(`yt-dlp stream url (direct source, format=${format})`, streamStartedAt);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
reportSlidesProgress?.('downloading video', P_FETCH_VIDEO);
|
|
312
|
+
const downloadStartedAt = Date.now();
|
|
313
|
+
try {
|
|
314
|
+
const downloaded = await downloadRemoteVideo({
|
|
315
|
+
url: source.url,
|
|
316
|
+
timeoutMs,
|
|
317
|
+
onProgress: (percent, detail) => {
|
|
318
|
+
const ratio = clamp(percent / 100, 0, 1);
|
|
319
|
+
const mapped = P_FETCH_VIDEO + ratio * (P_DOWNLOAD_VIDEO - P_FETCH_VIDEO);
|
|
320
|
+
reportSlidesProgress?.('downloading video', mapped, detail);
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
const cached = mediaCacheKey
|
|
324
|
+
? await mediaCache?.put({
|
|
325
|
+
url: mediaCacheKey,
|
|
326
|
+
filePath: downloaded.filePath,
|
|
327
|
+
filename: path.basename(downloaded.filePath),
|
|
328
|
+
})
|
|
329
|
+
: null;
|
|
330
|
+
inputPath = cached?.filePath ?? downloaded.filePath;
|
|
331
|
+
inputCleanup = downloaded.cleanup;
|
|
332
|
+
logSlidesTiming('download direct video (detect+extract)', downloadStartedAt);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (!allowStreamFallback) {
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
warnings.push(`Failed to download video; falling back to stream URL: ${String(error)}`);
|
|
339
|
+
inputPath = source.url;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const ffmpegStartedAt = Date.now();
|
|
345
|
+
reportSlidesProgress?.('detecting scenes', P_FETCH_VIDEO + 2);
|
|
346
|
+
const detection = await detectSlideTimestamps({
|
|
347
|
+
ffmpegPath: ffmpegBinary,
|
|
348
|
+
ffprobePath: ffprobeBinary,
|
|
349
|
+
inputPath,
|
|
350
|
+
sceneThreshold: settings.sceneThreshold,
|
|
351
|
+
autoTuneThreshold: settings.autoTuneThreshold,
|
|
352
|
+
env,
|
|
353
|
+
timeoutMs,
|
|
354
|
+
warnings,
|
|
355
|
+
workers,
|
|
356
|
+
sampleCount: resolveSlidesSampleCount(env),
|
|
357
|
+
onSegmentProgress: (completed, total) => {
|
|
358
|
+
const ratio = total > 0 ? completed / total : 0;
|
|
359
|
+
const mapped = P_FETCH_VIDEO + 2 + ratio * (P_DETECT_SCENES - (P_FETCH_VIDEO + 2));
|
|
360
|
+
reportSlidesProgress?.('detecting scenes', mapped, total > 0 ? `(${completed}/${total})` : undefined);
|
|
361
|
+
},
|
|
362
|
+
logSlides,
|
|
363
|
+
logSlidesTiming,
|
|
364
|
+
});
|
|
365
|
+
reportSlidesProgress?.('detecting scenes', P_DETECT_SCENES);
|
|
366
|
+
logSlidesTiming('ffmpeg scene-detect', ffmpegStartedAt);
|
|
367
|
+
const interval = buildIntervalTimestamps({
|
|
368
|
+
durationSeconds: detection.durationSeconds,
|
|
369
|
+
minDurationSeconds: settings.minDurationSeconds,
|
|
370
|
+
maxSlides: settings.maxSlides,
|
|
371
|
+
});
|
|
372
|
+
const combined = mergeTimestamps(detection.timestamps, interval?.timestamps ?? [], settings.minDurationSeconds);
|
|
373
|
+
if (combined.length === 0) {
|
|
374
|
+
throw new Error('No slides detected; try adjusting slide extraction settings.');
|
|
375
|
+
}
|
|
376
|
+
const sceneSegments = buildSceneSegments(detection.timestamps, detection.durationSeconds);
|
|
377
|
+
const selected = interval?.timestamps.length
|
|
378
|
+
? selectTimestampTargets({
|
|
379
|
+
targets: interval.timestamps,
|
|
380
|
+
sceneTimestamps: detection.timestamps,
|
|
381
|
+
minDurationSeconds: settings.minDurationSeconds,
|
|
382
|
+
intervalSeconds: interval.intervalSeconds,
|
|
383
|
+
})
|
|
384
|
+
: combined;
|
|
385
|
+
const spaced = filterTimestampsByMinDuration(selected, settings.minDurationSeconds);
|
|
386
|
+
const trimmed = applyMaxSlidesFilter(spaced.map((timestamp, index) => {
|
|
387
|
+
const segment = findSceneSegment(sceneSegments, timestamp);
|
|
388
|
+
const adjusted = adjustTimestampWithinSegment(timestamp, segment);
|
|
389
|
+
return { index: index + 1, timestamp: adjusted, imagePath: '', segment };
|
|
390
|
+
}), settings.maxSlides, warnings);
|
|
391
|
+
const timelineSlides = {
|
|
392
|
+
sourceUrl: source.url,
|
|
393
|
+
sourceKind: source.kind,
|
|
394
|
+
sourceId: source.sourceId,
|
|
395
|
+
slidesDir,
|
|
396
|
+
slidesDirId: buildSlidesDirId(slidesDir),
|
|
397
|
+
sceneThreshold: settings.sceneThreshold,
|
|
398
|
+
autoTuneThreshold: settings.autoTuneThreshold,
|
|
399
|
+
autoTune: detection.autoTune,
|
|
400
|
+
maxSlides: settings.maxSlides,
|
|
401
|
+
minSlideDuration: settings.minDurationSeconds,
|
|
402
|
+
ocrRequested: settings.ocr,
|
|
403
|
+
ocrAvailable,
|
|
404
|
+
slides: trimmed.map(({ segment: _segment, ...slide }) => slide),
|
|
405
|
+
warnings,
|
|
406
|
+
};
|
|
407
|
+
hooks?.onSlidesTimeline?.(timelineSlides);
|
|
408
|
+
// Emit placeholders immediately so the UI can render the slide list while frames are still extracting.
|
|
409
|
+
if (hooks?.onSlideChunk) {
|
|
410
|
+
const meta = {
|
|
411
|
+
slidesDir,
|
|
412
|
+
sourceUrl: source.url,
|
|
413
|
+
sourceId: source.sourceId,
|
|
414
|
+
sourceKind: source.kind,
|
|
415
|
+
ocrAvailable,
|
|
416
|
+
};
|
|
417
|
+
for (const slide of trimmed) {
|
|
418
|
+
const { segment: _segment, ...payload } = slide;
|
|
419
|
+
hooks.onSlideChunk({ slide: { ...payload, imagePath: '' }, meta });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const formatProgressCount = (completed, total) => total > 0 ? `(${completed}/${total})` : '';
|
|
423
|
+
const reportFrameProgress = (completed, total) => {
|
|
424
|
+
const ratio = total > 0 ? completed / total : 0;
|
|
425
|
+
reportSlidesProgress?.('extracting frames', P_DETECT_SCENES + ratio * (P_EXTRACT_FRAMES - P_DETECT_SCENES), formatProgressCount(completed, total));
|
|
426
|
+
};
|
|
427
|
+
reportFrameProgress(0, trimmed.length);
|
|
428
|
+
const onSlideChunk = hooks?.onSlideChunk;
|
|
429
|
+
const extractFrames = async () => extractFramesAtTimestamps({
|
|
430
|
+
ffmpegPath: ffmpegBinary,
|
|
431
|
+
inputPath,
|
|
432
|
+
outputDir: slidesDir,
|
|
433
|
+
timestamps: trimmed.map((slide) => slide.timestamp),
|
|
434
|
+
segments: trimmed.map((slide) => slide.segment ?? null),
|
|
435
|
+
durationSeconds: detection.durationSeconds,
|
|
436
|
+
timeoutMs,
|
|
437
|
+
workers,
|
|
438
|
+
onProgress: reportFrameProgress,
|
|
439
|
+
onStatus: hooks?.onSlidesProgress ?? null,
|
|
440
|
+
onSlide: onSlideChunk
|
|
441
|
+
? (slide) => onSlideChunk({
|
|
442
|
+
slide,
|
|
443
|
+
meta: {
|
|
444
|
+
slidesDir,
|
|
445
|
+
sourceUrl: source.url,
|
|
446
|
+
sourceId: source.sourceId,
|
|
447
|
+
sourceKind: source.kind,
|
|
448
|
+
ocrAvailable,
|
|
449
|
+
},
|
|
450
|
+
})
|
|
451
|
+
: null,
|
|
452
|
+
logSlides,
|
|
453
|
+
logSlidesTiming,
|
|
454
|
+
});
|
|
455
|
+
const extractFramesStartedAt = Date.now();
|
|
456
|
+
const extractedSlides = await extractFrames();
|
|
457
|
+
const extractElapsedMs = logSlidesTiming?.(`extract frames (count=${trimmed.length}, parallel=${workers})`, extractFramesStartedAt);
|
|
458
|
+
if (trimmed.length > 0 && typeof extractElapsedMs === 'number') {
|
|
459
|
+
logSlides?.(`extract frames avgMsPerFrame=${Math.round(extractElapsedMs / trimmed.length)}`);
|
|
460
|
+
}
|
|
461
|
+
const rawSlides = applyMinDurationFilter(extractedSlides, settings.minDurationSeconds, warnings);
|
|
462
|
+
const renameStartedAt = Date.now();
|
|
463
|
+
const renamedSlides = await renameSlidesWithTimestamps(rawSlides, slidesDir);
|
|
464
|
+
logSlidesTiming?.('rename slides', renameStartedAt);
|
|
465
|
+
if (renamedSlides.length === 0) {
|
|
466
|
+
throw new Error('No slides extracted; try lowering --slides-scene-threshold.');
|
|
467
|
+
}
|
|
468
|
+
let slidesWithOcr = renamedSlides;
|
|
469
|
+
if (ocrEnabled && tesseractPath) {
|
|
470
|
+
const ocrStartedAt = Date.now();
|
|
471
|
+
logSlides?.(`ocr start count=${renamedSlides.length} mode=parallel workers=${workers}`);
|
|
472
|
+
const ocrStartPercent = P_OCR - 3;
|
|
473
|
+
const reportOcrProgress = (completed, total) => {
|
|
474
|
+
const ratio = total > 0 ? completed / total : 0;
|
|
475
|
+
reportSlidesProgress?.('running OCR', ocrStartPercent + ratio * (P_OCR - ocrStartPercent), formatProgressCount(completed, total));
|
|
476
|
+
};
|
|
477
|
+
reportOcrProgress(0, renamedSlides.length);
|
|
478
|
+
slidesWithOcr = await runOcrOnSlides(renamedSlides, tesseractPath, workers, reportOcrProgress);
|
|
479
|
+
const elapsedMs = logSlidesTiming?.('ocr done', ocrStartedAt);
|
|
480
|
+
if (renamedSlides.length > 0 && typeof elapsedMs === 'number') {
|
|
481
|
+
logSlides?.(`ocr avgMsPerSlide=${Math.round(elapsedMs / renamedSlides.length)}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
reportSlidesProgress?.('finalizing', P_FINAL - 1);
|
|
485
|
+
if (hooks?.onSlideChunk) {
|
|
486
|
+
for (const slide of slidesWithOcr) {
|
|
487
|
+
hooks.onSlideChunk({
|
|
488
|
+
slide,
|
|
489
|
+
meta: {
|
|
490
|
+
slidesDir,
|
|
491
|
+
sourceUrl: source.url,
|
|
492
|
+
sourceId: source.sourceId,
|
|
493
|
+
sourceKind: source.kind,
|
|
494
|
+
ocrAvailable,
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const result = {
|
|
500
|
+
sourceUrl: source.url,
|
|
501
|
+
sourceKind: source.kind,
|
|
502
|
+
sourceId: source.sourceId,
|
|
503
|
+
slidesDir,
|
|
504
|
+
slidesDirId: buildSlidesDirId(slidesDir),
|
|
505
|
+
sceneThreshold: settings.sceneThreshold,
|
|
506
|
+
autoTuneThreshold: settings.autoTuneThreshold,
|
|
507
|
+
autoTune: detection.autoTune,
|
|
508
|
+
maxSlides: settings.maxSlides,
|
|
509
|
+
minSlideDuration: settings.minDurationSeconds,
|
|
510
|
+
ocrRequested: settings.ocr,
|
|
511
|
+
ocrAvailable,
|
|
512
|
+
slides: slidesWithOcr,
|
|
513
|
+
warnings,
|
|
514
|
+
};
|
|
515
|
+
await writeSlidesJson(result, slidesDir);
|
|
516
|
+
reportSlidesProgress?.('finalizing', P_FINAL);
|
|
517
|
+
logSlidesTiming('slides total', totalStartedAt);
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
finally {
|
|
521
|
+
if (inputCleanup) {
|
|
522
|
+
await inputCleanup();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}, () => {
|
|
526
|
+
hooks?.onSlidesProgress?.('Slides: queued');
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
export function parseShowinfoTimestamp(line) {
|
|
530
|
+
if (!line.includes('showinfo'))
|
|
531
|
+
return null;
|
|
532
|
+
const match = /pts_time:(\d+\.?\d*)/.exec(line);
|
|
533
|
+
if (!match)
|
|
534
|
+
return null;
|
|
535
|
+
const ts = Number(match[1]);
|
|
536
|
+
if (!Number.isFinite(ts))
|
|
537
|
+
return null;
|
|
538
|
+
return ts;
|
|
539
|
+
}
|
|
540
|
+
export function resolveExtractedTimestamp({ requested, actual, seekBase, }) {
|
|
541
|
+
if (!Number.isFinite(requested))
|
|
542
|
+
return 0;
|
|
543
|
+
if (actual == null || !Number.isFinite(actual) || actual < 0)
|
|
544
|
+
return requested;
|
|
545
|
+
const base = typeof seekBase === 'number' && Number.isFinite(seekBase) && seekBase > 0 ? seekBase : null;
|
|
546
|
+
if (!base) {
|
|
547
|
+
// With -ss before -i, showinfo PTS resets near 0. Treat small values as offsets.
|
|
548
|
+
if (actual <= 5)
|
|
549
|
+
return requested + actual;
|
|
550
|
+
return actual;
|
|
551
|
+
}
|
|
552
|
+
const candidateRelative = base + actual;
|
|
553
|
+
const candidateAbsolute = actual;
|
|
554
|
+
const relativeDelta = Math.abs(candidateRelative - requested);
|
|
555
|
+
const absoluteDelta = Math.abs(candidateAbsolute - requested);
|
|
556
|
+
return relativeDelta <= absoluteDelta ? candidateRelative : candidateAbsolute;
|
|
557
|
+
}
|
|
558
|
+
async function prepareSlidesDir(slidesDir) {
|
|
559
|
+
await fs.mkdir(slidesDir, { recursive: true });
|
|
560
|
+
const entries = await fs.readdir(slidesDir);
|
|
561
|
+
await Promise.all(entries.map(async (entry) => {
|
|
562
|
+
if (entry.startsWith('slide_') && entry.endsWith('.png')) {
|
|
563
|
+
await fs.rm(path.join(slidesDir, entry), { force: true });
|
|
564
|
+
}
|
|
565
|
+
if (entry === 'slides.json') {
|
|
566
|
+
await fs.rm(path.join(slidesDir, entry), { force: true });
|
|
567
|
+
}
|
|
568
|
+
}));
|
|
569
|
+
}
|
|
570
|
+
async function downloadYoutubeVideo({ ytDlpPath, url, timeoutMs, format, onProgress, }) {
|
|
571
|
+
const dir = await fs.mkdtemp(path.join(tmpdir(), `summarize-slides-${randomUUID()}-`));
|
|
572
|
+
const outputTemplate = path.join(dir, 'video.%(ext)s');
|
|
573
|
+
const progressTemplate = 'progress:%(progress.downloaded_bytes)s|%(progress.total_bytes)s|%(progress.total_bytes_estimate)s';
|
|
574
|
+
const args = [
|
|
575
|
+
'-f',
|
|
576
|
+
format,
|
|
577
|
+
'--no-playlist',
|
|
578
|
+
'--no-warnings',
|
|
579
|
+
'--concurrent-fragments',
|
|
580
|
+
'4',
|
|
581
|
+
...(onProgress ? ['--progress', '--newline', '--progress-template', progressTemplate] : []),
|
|
582
|
+
'-o',
|
|
583
|
+
outputTemplate,
|
|
584
|
+
url,
|
|
585
|
+
];
|
|
586
|
+
await runProcess({
|
|
587
|
+
command: ytDlpPath,
|
|
588
|
+
args,
|
|
589
|
+
timeoutMs: Math.max(timeoutMs, YT_DLP_TIMEOUT_MS),
|
|
590
|
+
errorLabel: 'yt-dlp',
|
|
591
|
+
onStderrLine: (line, handle) => {
|
|
592
|
+
if (!onProgress)
|
|
593
|
+
return;
|
|
594
|
+
const trimmed = line.trim();
|
|
595
|
+
if (trimmed.startsWith('progress:')) {
|
|
596
|
+
const payload = trimmed.slice('progress:'.length);
|
|
597
|
+
const [downloadedRaw, totalRaw, estimateRaw] = payload.split('|');
|
|
598
|
+
const downloaded = Number.parseFloat(downloadedRaw);
|
|
599
|
+
if (!Number.isFinite(downloaded) || downloaded < 0)
|
|
600
|
+
return;
|
|
601
|
+
const totalCandidate = Number.parseFloat(totalRaw);
|
|
602
|
+
const estimateCandidate = Number.parseFloat(estimateRaw);
|
|
603
|
+
const totalBytes = Number.isFinite(totalCandidate) && totalCandidate > 0
|
|
604
|
+
? totalCandidate
|
|
605
|
+
: Number.isFinite(estimateCandidate) && estimateCandidate > 0
|
|
606
|
+
? estimateCandidate
|
|
607
|
+
: null;
|
|
608
|
+
if (!totalBytes || totalBytes <= 0)
|
|
609
|
+
return;
|
|
610
|
+
const percent = Math.max(0, Math.min(100, Math.round((downloaded / totalBytes) * 100)));
|
|
611
|
+
const detail = `(${formatBytes(downloaded)}/${formatBytes(totalBytes)})`;
|
|
612
|
+
onProgress(percent, detail);
|
|
613
|
+
handle?.setProgress(percent, detail);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (!trimmed.startsWith('[download]'))
|
|
617
|
+
return;
|
|
618
|
+
const percentMatch = trimmed.match(/\b(\d{1,3}(?:\.\d+)?)%\b/);
|
|
619
|
+
if (!percentMatch)
|
|
620
|
+
return;
|
|
621
|
+
const percent = Number(percentMatch[1]);
|
|
622
|
+
if (!Number.isFinite(percent) || percent < 0 || percent > 100)
|
|
623
|
+
return;
|
|
624
|
+
const etaMatch = trimmed.match(/\bETA\s+(\S+)\b/);
|
|
625
|
+
const speedMatch = trimmed.match(/\bat\s+(\S+)\b/);
|
|
626
|
+
const detailParts = [
|
|
627
|
+
speedMatch?.[1] ? `at ${speedMatch[1]}` : null,
|
|
628
|
+
etaMatch?.[1] ? `ETA ${etaMatch[1]}` : null,
|
|
629
|
+
].filter(Boolean);
|
|
630
|
+
const detail = detailParts.length ? detailParts.join(' ') : undefined;
|
|
631
|
+
onProgress(percent, detail);
|
|
632
|
+
handle?.setProgress(percent, detail ?? null);
|
|
633
|
+
},
|
|
634
|
+
onStdoutLine: onProgress
|
|
635
|
+
? (line, handle) => {
|
|
636
|
+
if (!line.trim().startsWith('progress:'))
|
|
637
|
+
return;
|
|
638
|
+
const payload = line.trim().slice('progress:'.length);
|
|
639
|
+
const [downloadedRaw, totalRaw, estimateRaw] = payload.split('|');
|
|
640
|
+
const downloaded = Number.parseFloat(downloadedRaw);
|
|
641
|
+
if (!Number.isFinite(downloaded) || downloaded < 0)
|
|
642
|
+
return;
|
|
643
|
+
const totalCandidate = Number.parseFloat(totalRaw);
|
|
644
|
+
const estimateCandidate = Number.parseFloat(estimateRaw);
|
|
645
|
+
const totalBytes = Number.isFinite(totalCandidate) && totalCandidate > 0
|
|
646
|
+
? totalCandidate
|
|
647
|
+
: Number.isFinite(estimateCandidate) && estimateCandidate > 0
|
|
648
|
+
? estimateCandidate
|
|
649
|
+
: null;
|
|
650
|
+
if (!totalBytes || totalBytes <= 0)
|
|
651
|
+
return;
|
|
652
|
+
const percent = Math.max(0, Math.min(100, Math.round((downloaded / totalBytes) * 100)));
|
|
653
|
+
const detail = `(${formatBytes(downloaded)}/${formatBytes(totalBytes)})`;
|
|
654
|
+
onProgress(percent, detail);
|
|
655
|
+
handle?.setProgress(percent, detail);
|
|
656
|
+
}
|
|
657
|
+
: undefined,
|
|
658
|
+
});
|
|
659
|
+
const files = await fs.readdir(dir);
|
|
660
|
+
const candidates = [];
|
|
661
|
+
for (const entry of files) {
|
|
662
|
+
if (entry.endsWith('.part') || entry.endsWith('.ytdl'))
|
|
663
|
+
continue;
|
|
664
|
+
const filePath = path.join(dir, entry);
|
|
665
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
666
|
+
if (stat?.isFile()) {
|
|
667
|
+
candidates.push({ filePath, size: stat.size });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (candidates.length === 0) {
|
|
671
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
672
|
+
throw new Error('yt-dlp completed but no video file was downloaded.');
|
|
673
|
+
}
|
|
674
|
+
candidates.sort((a, b) => b.size - a.size);
|
|
675
|
+
const filePath = candidates[0].filePath;
|
|
676
|
+
return {
|
|
677
|
+
filePath,
|
|
678
|
+
cleanup: async () => {
|
|
679
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
async function downloadRemoteVideo({ url, timeoutMs, onProgress, }) {
|
|
684
|
+
const dir = await fs.mkdtemp(path.join(tmpdir(), `summarize-slides-${randomUUID()}-`));
|
|
685
|
+
let suffix = '.bin';
|
|
686
|
+
try {
|
|
687
|
+
const parsed = new URL(url);
|
|
688
|
+
const ext = path.extname(parsed.pathname);
|
|
689
|
+
if (ext)
|
|
690
|
+
suffix = ext;
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
// ignore
|
|
694
|
+
}
|
|
695
|
+
const filePath = path.join(dir, `video${suffix}`);
|
|
696
|
+
const controller = new AbortController();
|
|
697
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
698
|
+
try {
|
|
699
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
700
|
+
if (!res.ok) {
|
|
701
|
+
throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
|
702
|
+
}
|
|
703
|
+
const totalRaw = res.headers.get('content-length');
|
|
704
|
+
const total = totalRaw ? Number(totalRaw) : 0;
|
|
705
|
+
const hasTotal = Number.isFinite(total) && total > 0;
|
|
706
|
+
const reader = res.body?.getReader();
|
|
707
|
+
if (!reader) {
|
|
708
|
+
throw new Error('Download failed: missing response body');
|
|
709
|
+
}
|
|
710
|
+
const handle = await fs.open(filePath, 'w');
|
|
711
|
+
let downloaded = 0;
|
|
712
|
+
let lastPercent = -1;
|
|
713
|
+
let lastReportedBytes = 0;
|
|
714
|
+
const reportProgress = () => {
|
|
715
|
+
if (!onProgress)
|
|
716
|
+
return;
|
|
717
|
+
if (hasTotal) {
|
|
718
|
+
const percent = Math.max(0, Math.min(100, Math.round((downloaded / total) * 100)));
|
|
719
|
+
if (percent === lastPercent)
|
|
720
|
+
return;
|
|
721
|
+
lastPercent = percent;
|
|
722
|
+
const detail = `(${formatBytes(downloaded)}/${formatBytes(total)})`;
|
|
723
|
+
onProgress(percent, detail);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (downloaded - lastReportedBytes < 2 * 1024 * 1024)
|
|
727
|
+
return;
|
|
728
|
+
lastReportedBytes = downloaded;
|
|
729
|
+
onProgress(0, `(${formatBytes(downloaded)})`);
|
|
730
|
+
};
|
|
731
|
+
try {
|
|
732
|
+
while (true) {
|
|
733
|
+
const { done, value } = await reader.read();
|
|
734
|
+
if (done)
|
|
735
|
+
break;
|
|
736
|
+
if (!value)
|
|
737
|
+
continue;
|
|
738
|
+
await handle.write(value);
|
|
739
|
+
downloaded += value.byteLength;
|
|
740
|
+
reportProgress();
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
await handle.close();
|
|
745
|
+
}
|
|
746
|
+
if (hasTotal) {
|
|
747
|
+
onProgress?.(100, `(${formatBytes(downloaded)}/${formatBytes(total)})`);
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
filePath,
|
|
751
|
+
cleanup: async () => {
|
|
752
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
753
|
+
},
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => null);
|
|
758
|
+
throw error;
|
|
759
|
+
}
|
|
760
|
+
finally {
|
|
761
|
+
clearTimeout(timeout);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
async function resolveYoutubeStreamUrl({ ytDlpPath, url, timeoutMs, format, }) {
|
|
765
|
+
const args = ['-f', format, '-g', url];
|
|
766
|
+
const output = await runProcessCapture({
|
|
767
|
+
command: ytDlpPath,
|
|
768
|
+
args,
|
|
769
|
+
timeoutMs: Math.max(timeoutMs, YT_DLP_TIMEOUT_MS),
|
|
770
|
+
errorLabel: 'yt-dlp',
|
|
771
|
+
});
|
|
772
|
+
const lines = output
|
|
773
|
+
.split('\n')
|
|
774
|
+
.map((line) => line.trim())
|
|
775
|
+
.filter(Boolean);
|
|
776
|
+
if (lines.length === 0) {
|
|
777
|
+
throw new Error('yt-dlp did not return a stream URL.');
|
|
778
|
+
}
|
|
779
|
+
return lines[0];
|
|
780
|
+
}
|
|
781
|
+
async function detectSlideTimestamps({ ffmpegPath, ffprobePath, inputPath, sceneThreshold, autoTuneThreshold, env, timeoutMs, warnings, workers, sampleCount, onSegmentProgress, logSlides, logSlidesTiming, }) {
|
|
782
|
+
const probeStartedAt = Date.now();
|
|
783
|
+
const videoInfo = await probeVideoInfo({
|
|
784
|
+
ffprobePath,
|
|
785
|
+
env,
|
|
786
|
+
inputPath,
|
|
787
|
+
timeoutMs,
|
|
788
|
+
});
|
|
789
|
+
logSlidesTiming?.('ffprobe video info', probeStartedAt);
|
|
790
|
+
const calibration = await calibrateSceneThreshold({
|
|
791
|
+
ffmpegPath,
|
|
792
|
+
inputPath,
|
|
793
|
+
durationSeconds: videoInfo.durationSeconds,
|
|
794
|
+
sampleCount,
|
|
795
|
+
timeoutMs,
|
|
796
|
+
logSlides,
|
|
797
|
+
});
|
|
798
|
+
const baseThreshold = sceneThreshold;
|
|
799
|
+
const calibratedThreshold = calibration.threshold;
|
|
800
|
+
const chosenThreshold = autoTuneThreshold ? calibratedThreshold : baseThreshold;
|
|
801
|
+
if (autoTuneThreshold && chosenThreshold !== baseThreshold) {
|
|
802
|
+
warnings.push(`Auto-tuned scene threshold from ${baseThreshold} to ${chosenThreshold}`);
|
|
803
|
+
}
|
|
804
|
+
const segments = buildSegments(videoInfo.durationSeconds, workers);
|
|
805
|
+
const detectStartedAt = Date.now();
|
|
806
|
+
let effectiveThreshold = chosenThreshold;
|
|
807
|
+
let timestamps = await detectSceneTimestamps({
|
|
808
|
+
ffmpegPath,
|
|
809
|
+
inputPath,
|
|
810
|
+
threshold: effectiveThreshold,
|
|
811
|
+
timeoutMs,
|
|
812
|
+
segments,
|
|
813
|
+
workers,
|
|
814
|
+
onSegmentProgress,
|
|
815
|
+
});
|
|
816
|
+
logSlidesTiming?.(`scene detection base (threshold=${effectiveThreshold}, segments=${segments.length})`, detectStartedAt);
|
|
817
|
+
if (timestamps.length === 0) {
|
|
818
|
+
const fallbackThreshold = Math.max(0.05, roundThreshold(effectiveThreshold * 0.5));
|
|
819
|
+
if (fallbackThreshold !== effectiveThreshold) {
|
|
820
|
+
const retryStartedAt = Date.now();
|
|
821
|
+
timestamps = await detectSceneTimestamps({
|
|
822
|
+
ffmpegPath,
|
|
823
|
+
inputPath,
|
|
824
|
+
threshold: fallbackThreshold,
|
|
825
|
+
timeoutMs,
|
|
826
|
+
segments,
|
|
827
|
+
workers,
|
|
828
|
+
onSegmentProgress,
|
|
829
|
+
});
|
|
830
|
+
logSlidesTiming?.(`scene detection retry (threshold=${fallbackThreshold}, segments=${segments.length})`, retryStartedAt);
|
|
831
|
+
warnings.push(`Scene detection retry used lower threshold ${fallbackThreshold} after zero detections`);
|
|
832
|
+
if (timestamps.length > 0) {
|
|
833
|
+
effectiveThreshold = fallbackThreshold;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const autoTune = autoTuneThreshold
|
|
838
|
+
? {
|
|
839
|
+
enabled: true,
|
|
840
|
+
chosenThreshold: timestamps.length > 0 ? effectiveThreshold : baseThreshold,
|
|
841
|
+
confidence: calibration.confidence,
|
|
842
|
+
strategy: 'hash',
|
|
843
|
+
}
|
|
844
|
+
: {
|
|
845
|
+
enabled: false,
|
|
846
|
+
chosenThreshold: baseThreshold,
|
|
847
|
+
confidence: 0,
|
|
848
|
+
strategy: 'none',
|
|
849
|
+
};
|
|
850
|
+
return { timestamps, autoTune, durationSeconds: videoInfo.durationSeconds };
|
|
851
|
+
}
|
|
852
|
+
async function extractFramesAtTimestamps({ ffmpegPath, inputPath, outputDir, timestamps, segments, durationSeconds, timeoutMs, workers, onProgress, onStatus, onSlide, logSlides, logSlidesTiming, }) {
|
|
853
|
+
const FRAME_ADJUST_RANGE_SECONDS = 10;
|
|
854
|
+
const FRAME_ADJUST_STEP_SECONDS = 2;
|
|
855
|
+
const FRAME_MIN_BRIGHTNESS = 0.24;
|
|
856
|
+
const FRAME_MIN_CONTRAST = 0.16;
|
|
857
|
+
const SEEK_PAD_SECONDS = 8;
|
|
858
|
+
const clampTimestamp = (value) => {
|
|
859
|
+
const upper = typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
|
|
860
|
+
? Math.max(0, durationSeconds - 0.1)
|
|
861
|
+
: Number.POSITIVE_INFINITY;
|
|
862
|
+
return clamp(value, 0, upper);
|
|
863
|
+
};
|
|
864
|
+
const resolveSegmentBounds = (segment) => {
|
|
865
|
+
if (!segment)
|
|
866
|
+
return null;
|
|
867
|
+
const start = Math.max(0, segment.start);
|
|
868
|
+
const end = typeof segment.end === 'number' && Number.isFinite(segment.end) ? segment.end : null;
|
|
869
|
+
if (end != null && end <= start)
|
|
870
|
+
return null;
|
|
871
|
+
return { start, end };
|
|
872
|
+
};
|
|
873
|
+
const resolveSegmentPadding = (segment) => {
|
|
874
|
+
if (!segment || segment.end == null)
|
|
875
|
+
return 0;
|
|
876
|
+
const duration = Math.max(0, segment.end - segment.start);
|
|
877
|
+
if (duration <= 0)
|
|
878
|
+
return 0;
|
|
879
|
+
return Math.min(1.5, Math.max(0.2, duration * 0.08));
|
|
880
|
+
};
|
|
881
|
+
const parseSignalstats = (line, stats) => {
|
|
882
|
+
if (!line.includes('lavfi.signalstats.'))
|
|
883
|
+
return;
|
|
884
|
+
const match = line.match(/lavfi\.signalstats\.(YMIN|YMAX|YAVG)=(\d+(?:\.\d+)?)/);
|
|
885
|
+
if (!match)
|
|
886
|
+
return;
|
|
887
|
+
const value = Number(match[2]);
|
|
888
|
+
if (!Number.isFinite(value))
|
|
889
|
+
return;
|
|
890
|
+
if (match[1] === 'YMIN')
|
|
891
|
+
stats.ymin = value;
|
|
892
|
+
if (match[1] === 'YMAX')
|
|
893
|
+
stats.ymax = value;
|
|
894
|
+
if (match[1] === 'YAVG')
|
|
895
|
+
stats.yavg = value;
|
|
896
|
+
};
|
|
897
|
+
const toQuality = (stats) => {
|
|
898
|
+
if (stats.ymin == null || stats.ymax == null || stats.yavg == null)
|
|
899
|
+
return null;
|
|
900
|
+
const brightness = clamp(stats.yavg / 255, 0, 1);
|
|
901
|
+
const contrast = clamp((stats.ymax - stats.ymin) / 255, 0, 1);
|
|
902
|
+
return { brightness, contrast };
|
|
903
|
+
};
|
|
904
|
+
const scoreQuality = (quality, deltaSeconds) => {
|
|
905
|
+
const penalty = Math.min(1, Math.abs(deltaSeconds) / FRAME_ADJUST_RANGE_SECONDS) * 0.05;
|
|
906
|
+
// Prefer brighter frames (dark-but-contrasty thumbnails are still unpleasant).
|
|
907
|
+
return quality.brightness * 0.55 + quality.contrast * 0.45 - penalty;
|
|
908
|
+
};
|
|
909
|
+
const extractFrame = async (timestamp, outputPath, opts) => {
|
|
910
|
+
const stats = { ymin: null, ymax: null, yavg: null };
|
|
911
|
+
let actualTimestamp = null;
|
|
912
|
+
const effectiveTimeoutMs = typeof opts?.timeoutMs === 'number' && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
|
|
913
|
+
? opts.timeoutMs
|
|
914
|
+
: timeoutMs;
|
|
915
|
+
const seekBase = Math.max(0, timestamp - SEEK_PAD_SECONDS);
|
|
916
|
+
const seekOffset = Math.max(0, timestamp - seekBase);
|
|
917
|
+
const args = [
|
|
918
|
+
'-hide_banner',
|
|
919
|
+
...(seekBase > 0 ? ['-ss', String(seekBase)] : []),
|
|
920
|
+
'-i',
|
|
921
|
+
inputPath,
|
|
922
|
+
...(seekOffset > 0 ? ['-ss', String(seekOffset)] : []),
|
|
923
|
+
'-vf',
|
|
924
|
+
'signalstats,showinfo,metadata=print',
|
|
925
|
+
'-vframes',
|
|
926
|
+
'1',
|
|
927
|
+
'-q:v',
|
|
928
|
+
'2',
|
|
929
|
+
'-an',
|
|
930
|
+
'-sn',
|
|
931
|
+
'-update',
|
|
932
|
+
'1',
|
|
933
|
+
outputPath,
|
|
934
|
+
];
|
|
935
|
+
await runProcess({
|
|
936
|
+
command: ffmpegPath,
|
|
937
|
+
args,
|
|
938
|
+
timeoutMs: effectiveTimeoutMs,
|
|
939
|
+
errorLabel: 'ffmpeg',
|
|
940
|
+
onStderrLine: (line) => {
|
|
941
|
+
if (actualTimestamp == null) {
|
|
942
|
+
const parsed = parseShowinfoTimestamp(line);
|
|
943
|
+
if (parsed != null)
|
|
944
|
+
actualTimestamp = parsed;
|
|
945
|
+
}
|
|
946
|
+
parseSignalstats(line, stats);
|
|
947
|
+
},
|
|
948
|
+
});
|
|
949
|
+
const stat = await fs.stat(outputPath).catch(() => null);
|
|
950
|
+
if (!stat?.isFile() || stat.size === 0) {
|
|
951
|
+
throw new Error(`ffmpeg produced no output frame at ${outputPath}`);
|
|
952
|
+
}
|
|
953
|
+
const quality = toQuality(stats);
|
|
954
|
+
return {
|
|
955
|
+
slide: { index: 0, timestamp, imagePath: outputPath },
|
|
956
|
+
quality,
|
|
957
|
+
actualTimestamp,
|
|
958
|
+
seekBase,
|
|
959
|
+
};
|
|
960
|
+
};
|
|
961
|
+
const slides = [];
|
|
962
|
+
const startedAt = Date.now();
|
|
963
|
+
const tasks = timestamps.map((timestamp, index) => async () => {
|
|
964
|
+
const segment = segments?.[index] ?? null;
|
|
965
|
+
const bounds = resolveSegmentBounds(segment);
|
|
966
|
+
const padding = resolveSegmentPadding(segment);
|
|
967
|
+
const clampedTimestamp = clampTimestamp(timestamp);
|
|
968
|
+
const safeTimestamp = bounds && bounds.end != null
|
|
969
|
+
? bounds.end - padding <= bounds.start + padding
|
|
970
|
+
? clampTimestamp(bounds.start + (bounds.end - bounds.start) * 0.5)
|
|
971
|
+
: clamp(clampedTimestamp, bounds.start + padding, bounds.end - padding)
|
|
972
|
+
: bounds
|
|
973
|
+
? Math.max(bounds.start + padding, clampedTimestamp)
|
|
974
|
+
: clampedTimestamp;
|
|
975
|
+
const outputPath = path.join(outputDir, `slide_${String(index + 1).padStart(4, '0')}.png`);
|
|
976
|
+
const extracted = await extractFrame(safeTimestamp, outputPath);
|
|
977
|
+
const resolvedTimestamp = resolveExtractedTimestamp({
|
|
978
|
+
requested: safeTimestamp,
|
|
979
|
+
actual: extracted.actualTimestamp,
|
|
980
|
+
seekBase: extracted.seekBase,
|
|
981
|
+
});
|
|
982
|
+
const delta = resolvedTimestamp - safeTimestamp;
|
|
983
|
+
if (Math.abs(delta) >= 0.25) {
|
|
984
|
+
const actualLabel = extracted.actualTimestamp != null && Number.isFinite(extracted.actualTimestamp)
|
|
985
|
+
? extracted.actualTimestamp.toFixed(2)
|
|
986
|
+
: 'n/a';
|
|
987
|
+
logSlides?.(`frame pts slide=${index + 1} req=${safeTimestamp.toFixed(2)}s actual=${actualLabel}s base=${extracted.seekBase.toFixed(2)}s -> ${resolvedTimestamp.toFixed(2)}s delta=${delta.toFixed(2)}s`);
|
|
988
|
+
}
|
|
989
|
+
const imageVersion = Date.now();
|
|
990
|
+
onSlide?.({
|
|
991
|
+
index: index + 1,
|
|
992
|
+
timestamp: resolvedTimestamp,
|
|
993
|
+
imagePath: outputPath,
|
|
994
|
+
imageVersion,
|
|
995
|
+
});
|
|
996
|
+
return {
|
|
997
|
+
index: index + 1,
|
|
998
|
+
timestamp: resolvedTimestamp,
|
|
999
|
+
requestedTimestamp: safeTimestamp,
|
|
1000
|
+
imagePath: outputPath,
|
|
1001
|
+
quality: extracted.quality,
|
|
1002
|
+
imageVersion,
|
|
1003
|
+
segment: bounds,
|
|
1004
|
+
};
|
|
1005
|
+
});
|
|
1006
|
+
const results = await runWithConcurrency(tasks, workers, onProgress ?? undefined);
|
|
1007
|
+
const ordered = results.filter(Boolean).sort((a, b) => a.index - b.index);
|
|
1008
|
+
const fixTasks = [];
|
|
1009
|
+
for (const frame of ordered) {
|
|
1010
|
+
slides.push({
|
|
1011
|
+
index: frame.index,
|
|
1012
|
+
timestamp: frame.timestamp,
|
|
1013
|
+
imagePath: frame.imagePath,
|
|
1014
|
+
imageVersion: frame.imageVersion,
|
|
1015
|
+
});
|
|
1016
|
+
const quality = frame.quality;
|
|
1017
|
+
if (!quality)
|
|
1018
|
+
continue;
|
|
1019
|
+
const shouldPreferBrighterFirstSlide = frame.index === 1 && frame.timestamp < 8;
|
|
1020
|
+
const needsAdjust = quality.brightness < FRAME_MIN_BRIGHTNESS ||
|
|
1021
|
+
quality.contrast < FRAME_MIN_CONTRAST ||
|
|
1022
|
+
(shouldPreferBrighterFirstSlide && (quality.brightness < 0.58 || quality.contrast < 0.2));
|
|
1023
|
+
if (!needsAdjust)
|
|
1024
|
+
continue;
|
|
1025
|
+
fixTasks.push(async () => {
|
|
1026
|
+
const bounds = resolveSegmentBounds(frame.segment ?? null);
|
|
1027
|
+
const padding = resolveSegmentPadding(frame.segment ?? null);
|
|
1028
|
+
const minTs = bounds
|
|
1029
|
+
? clampTimestamp(bounds.start + padding)
|
|
1030
|
+
: clampTimestamp(frame.timestamp - FRAME_ADJUST_RANGE_SECONDS);
|
|
1031
|
+
const maxTs = bounds && bounds.end != null
|
|
1032
|
+
? clampTimestamp(bounds.end - padding)
|
|
1033
|
+
: clampTimestamp(frame.timestamp + FRAME_ADJUST_RANGE_SECONDS);
|
|
1034
|
+
if (maxTs <= minTs)
|
|
1035
|
+
return;
|
|
1036
|
+
const baseTimestamp = clamp(frame.timestamp, minTs, maxTs);
|
|
1037
|
+
const maxRange = Math.min(FRAME_ADJUST_RANGE_SECONDS, maxTs - minTs);
|
|
1038
|
+
if (!Number.isFinite(maxRange) || maxRange < FRAME_ADJUST_STEP_SECONDS)
|
|
1039
|
+
return;
|
|
1040
|
+
const candidateOffsets = [];
|
|
1041
|
+
for (let offset = FRAME_ADJUST_STEP_SECONDS; offset <= maxRange; offset += FRAME_ADJUST_STEP_SECONDS) {
|
|
1042
|
+
candidateOffsets.push(offset, -offset);
|
|
1043
|
+
}
|
|
1044
|
+
let best = {
|
|
1045
|
+
timestamp: baseTimestamp,
|
|
1046
|
+
offsetSeconds: 0,
|
|
1047
|
+
quality,
|
|
1048
|
+
score: scoreQuality(quality, 0),
|
|
1049
|
+
};
|
|
1050
|
+
let selectedTimestamp = baseTimestamp;
|
|
1051
|
+
let didReplace = false;
|
|
1052
|
+
const minImproveDelta = shouldPreferBrighterFirstSlide ? 0.015 : 0.03;
|
|
1053
|
+
for (const offsetSeconds of candidateOffsets) {
|
|
1054
|
+
if (offsetSeconds === 0)
|
|
1055
|
+
continue;
|
|
1056
|
+
const candidateTimestamp = clamp(baseTimestamp + offsetSeconds, minTs, maxTs);
|
|
1057
|
+
if (Math.abs(candidateTimestamp - baseTimestamp) < 0.01)
|
|
1058
|
+
continue;
|
|
1059
|
+
const tempPath = path.join(outputDir, `slide_${String(frame.index).padStart(4, '0')}_alt.png`);
|
|
1060
|
+
try {
|
|
1061
|
+
const candidate = await extractFrame(candidateTimestamp, tempPath, {
|
|
1062
|
+
timeoutMs: Math.min(timeoutMs, 12_000),
|
|
1063
|
+
});
|
|
1064
|
+
if (!candidate.quality)
|
|
1065
|
+
continue;
|
|
1066
|
+
const resolvedCandidateTimestamp = resolveExtractedTimestamp({
|
|
1067
|
+
requested: candidateTimestamp,
|
|
1068
|
+
actual: candidate.actualTimestamp,
|
|
1069
|
+
seekBase: candidate.seekBase,
|
|
1070
|
+
});
|
|
1071
|
+
const score = scoreQuality(candidate.quality, offsetSeconds);
|
|
1072
|
+
if (score > best.score + minImproveDelta) {
|
|
1073
|
+
best = {
|
|
1074
|
+
timestamp: resolvedCandidateTimestamp,
|
|
1075
|
+
offsetSeconds,
|
|
1076
|
+
quality: candidate.quality,
|
|
1077
|
+
score,
|
|
1078
|
+
};
|
|
1079
|
+
try {
|
|
1080
|
+
await fs.rename(tempPath, frame.imagePath);
|
|
1081
|
+
}
|
|
1082
|
+
catch (err) {
|
|
1083
|
+
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : '';
|
|
1084
|
+
if (code === 'EEXIST') {
|
|
1085
|
+
await fs.rm(frame.imagePath, { force: true }).catch(() => null);
|
|
1086
|
+
await fs.rename(tempPath, frame.imagePath);
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
throw err;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
didReplace = true;
|
|
1093
|
+
selectedTimestamp = resolvedCandidateTimestamp;
|
|
1094
|
+
}
|
|
1095
|
+
else {
|
|
1096
|
+
await fs.rm(tempPath, { force: true }).catch(() => null);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
catch {
|
|
1100
|
+
await fs.rm(tempPath, { force: true }).catch(() => null);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
if (!didReplace)
|
|
1104
|
+
return;
|
|
1105
|
+
const updatedVersion = Date.now();
|
|
1106
|
+
const slide = slides[frame.index - 1];
|
|
1107
|
+
if (slide) {
|
|
1108
|
+
slide.imageVersion = updatedVersion;
|
|
1109
|
+
slide.timestamp = selectedTimestamp;
|
|
1110
|
+
}
|
|
1111
|
+
if (selectedTimestamp !== frame.timestamp) {
|
|
1112
|
+
const offsetSeconds = (selectedTimestamp - frame.timestamp).toFixed(2);
|
|
1113
|
+
const baseBrightness = quality.brightness.toFixed(2);
|
|
1114
|
+
const baseContrast = quality.contrast.toFixed(2);
|
|
1115
|
+
const bestBrightness = best.quality?.brightness?.toFixed(2) ?? baseBrightness;
|
|
1116
|
+
const bestContrast = best.quality?.contrast?.toFixed(2) ?? baseContrast;
|
|
1117
|
+
logSlides?.(`thumbnail adjust slide=${frame.index} ts=${frame.timestamp.toFixed(2)}s -> ${selectedTimestamp.toFixed(2)}s offset=${offsetSeconds}s base=${baseBrightness}/${baseContrast} best=${bestBrightness}/${bestContrast}`);
|
|
1118
|
+
}
|
|
1119
|
+
onSlide?.({
|
|
1120
|
+
index: frame.index,
|
|
1121
|
+
timestamp: selectedTimestamp,
|
|
1122
|
+
imagePath: frame.imagePath,
|
|
1123
|
+
imageVersion: updatedVersion,
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
if (fixTasks.length > 0) {
|
|
1128
|
+
const fixStartedAt = Date.now();
|
|
1129
|
+
const THUMB_START = 90;
|
|
1130
|
+
const THUMB_END = 96;
|
|
1131
|
+
// Avoid UI "stuck" at a static percent while we do expensive refinement passes.
|
|
1132
|
+
onStatus?.(`Slides: improving thumbnails ${THUMB_START}%`);
|
|
1133
|
+
logSlides?.(`thumbnail adjust start count=${fixTasks.length} range=±${FRAME_ADJUST_RANGE_SECONDS}s step=${FRAME_ADJUST_STEP_SECONDS}s`);
|
|
1134
|
+
await runWithConcurrency(fixTasks, Math.min(4, workers), (completed, total) => {
|
|
1135
|
+
const ratio = total > 0 ? completed / total : 0;
|
|
1136
|
+
const percent = Math.round(THUMB_START + ratio * (THUMB_END - THUMB_START));
|
|
1137
|
+
onStatus?.(`Slides: improving thumbnails ${percent}%`);
|
|
1138
|
+
});
|
|
1139
|
+
onStatus?.(`Slides: improving thumbnails ${THUMB_END}%`);
|
|
1140
|
+
logSlidesTiming?.('thumbnail adjust done', fixStartedAt);
|
|
1141
|
+
}
|
|
1142
|
+
logSlidesTiming?.(`extract frame loop (count=${timestamps.length}, workers=${workers})`, startedAt);
|
|
1143
|
+
return slides;
|
|
1144
|
+
}
|
|
1145
|
+
function clamp(value, min, max) {
|
|
1146
|
+
if (value < min)
|
|
1147
|
+
return min;
|
|
1148
|
+
if (value > max)
|
|
1149
|
+
return max;
|
|
1150
|
+
return value;
|
|
1151
|
+
}
|
|
1152
|
+
function buildCalibrationSampleTimestamps(durationSeconds, sampleCount) {
|
|
1153
|
+
if (!durationSeconds || durationSeconds <= 0)
|
|
1154
|
+
return [0];
|
|
1155
|
+
const clamped = Math.max(3, Math.min(12, Math.round(sampleCount)));
|
|
1156
|
+
const startRatio = 0.05;
|
|
1157
|
+
const endRatio = 0.95;
|
|
1158
|
+
if (clamped === 1) {
|
|
1159
|
+
return [clamp(durationSeconds * 0.5, 0, durationSeconds - 0.1)];
|
|
1160
|
+
}
|
|
1161
|
+
const step = (endRatio - startRatio) / (clamped - 1);
|
|
1162
|
+
const points = [];
|
|
1163
|
+
for (let i = 0; i < clamped; i += 1) {
|
|
1164
|
+
const ratio = startRatio + step * i;
|
|
1165
|
+
points.push(clamp(durationSeconds * ratio, 0, durationSeconds - 0.1));
|
|
1166
|
+
}
|
|
1167
|
+
return points;
|
|
1168
|
+
}
|
|
1169
|
+
function computeDiffStats(values) {
|
|
1170
|
+
if (values.length === 0) {
|
|
1171
|
+
return { median: 0, p75: 0, p90: 0, max: 0 };
|
|
1172
|
+
}
|
|
1173
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
1174
|
+
const at = (p) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.round(p)))] ?? 0;
|
|
1175
|
+
const median = at((sorted.length - 1) * 0.5);
|
|
1176
|
+
const p75 = at((sorted.length - 1) * 0.75);
|
|
1177
|
+
const p90 = at((sorted.length - 1) * 0.9);
|
|
1178
|
+
const max = sorted[sorted.length - 1] ?? 0;
|
|
1179
|
+
return { median, p75, p90, max };
|
|
1180
|
+
}
|
|
1181
|
+
function roundThreshold(value) {
|
|
1182
|
+
return Math.round(value * 100) / 100;
|
|
1183
|
+
}
|
|
1184
|
+
async function calibrateSceneThreshold({ ffmpegPath, inputPath, durationSeconds, sampleCount, timeoutMs, logSlides, }) {
|
|
1185
|
+
const timestamps = buildCalibrationSampleTimestamps(durationSeconds, sampleCount);
|
|
1186
|
+
if (timestamps.length < 2) {
|
|
1187
|
+
return { threshold: 0.2, confidence: 0 };
|
|
1188
|
+
}
|
|
1189
|
+
const hashes = [];
|
|
1190
|
+
for (const timestamp of timestamps) {
|
|
1191
|
+
const hash = await hashFrameAtTimestamp({
|
|
1192
|
+
ffmpegPath,
|
|
1193
|
+
inputPath,
|
|
1194
|
+
timestamp,
|
|
1195
|
+
timeoutMs,
|
|
1196
|
+
});
|
|
1197
|
+
if (hash)
|
|
1198
|
+
hashes.push(hash);
|
|
1199
|
+
}
|
|
1200
|
+
const diffs = [];
|
|
1201
|
+
for (let i = 1; i < hashes.length; i += 1) {
|
|
1202
|
+
const diff = computeHashDistanceRatio(hashes[i - 1], hashes[i]);
|
|
1203
|
+
diffs.push(diff);
|
|
1204
|
+
}
|
|
1205
|
+
const stats = computeDiffStats(diffs);
|
|
1206
|
+
const scaledMedian = stats.median * 0.15;
|
|
1207
|
+
const scaledP75 = stats.p75 * 0.2;
|
|
1208
|
+
const scaledP90 = stats.p90 * 0.25;
|
|
1209
|
+
let threshold = roundThreshold(Math.max(scaledMedian, scaledP75, scaledP90));
|
|
1210
|
+
if (stats.p75 >= 0.12) {
|
|
1211
|
+
threshold = Math.min(threshold, 0.05);
|
|
1212
|
+
}
|
|
1213
|
+
else if (stats.p90 < 0.05) {
|
|
1214
|
+
threshold = 0.05;
|
|
1215
|
+
}
|
|
1216
|
+
threshold = clamp(threshold, 0.05, 0.3);
|
|
1217
|
+
const confidence = diffs.length >= 2 ? clamp(stats.p75 / 0.25, 0, 1) : clamp(stats.max / 0.25, 0, 1);
|
|
1218
|
+
logSlides?.(`calibration samples=${timestamps.length} diffs=${diffs.length} median=${stats.median.toFixed(3)} p75=${stats.p75.toFixed(3)} threshold=${threshold}`);
|
|
1219
|
+
return { threshold, confidence };
|
|
1220
|
+
}
|
|
1221
|
+
function buildSegments(durationSeconds, workers) {
|
|
1222
|
+
if (!durationSeconds || durationSeconds <= 0 || workers <= 1) {
|
|
1223
|
+
return [{ start: 0, duration: durationSeconds ?? 0 }];
|
|
1224
|
+
}
|
|
1225
|
+
const clampedWorkers = Math.max(1, Math.min(16, Math.round(workers)));
|
|
1226
|
+
const segmentCount = Math.min(clampedWorkers, Math.ceil(durationSeconds / 60));
|
|
1227
|
+
const segmentDuration = durationSeconds / segmentCount;
|
|
1228
|
+
const segments = [];
|
|
1229
|
+
for (let i = 0; i < segmentCount; i += 1) {
|
|
1230
|
+
const start = i * segmentDuration;
|
|
1231
|
+
const remaining = durationSeconds - start;
|
|
1232
|
+
const duration = i === segmentCount - 1 ? remaining : segmentDuration;
|
|
1233
|
+
segments.push({ start, duration });
|
|
1234
|
+
}
|
|
1235
|
+
return segments;
|
|
1236
|
+
}
|
|
1237
|
+
async function detectSceneTimestamps({ ffmpegPath, inputPath, threshold, timeoutMs, segments, workers, onSegmentProgress, }) {
|
|
1238
|
+
const filter = `select='gt(scene,${threshold})',showinfo`;
|
|
1239
|
+
const defaultSegments = [{ start: 0, duration: 0 }];
|
|
1240
|
+
const usedSegments = segments && segments.length > 0 ? segments : defaultSegments;
|
|
1241
|
+
const concurrency = workers && workers > 0 ? workers : 1;
|
|
1242
|
+
const tasks = usedSegments.map((segment) => async () => {
|
|
1243
|
+
const args = [
|
|
1244
|
+
'-hide_banner',
|
|
1245
|
+
...(segment.duration > 0
|
|
1246
|
+
? ['-ss', String(segment.start), '-t', String(segment.duration)]
|
|
1247
|
+
: []),
|
|
1248
|
+
'-i',
|
|
1249
|
+
inputPath,
|
|
1250
|
+
'-vf',
|
|
1251
|
+
filter,
|
|
1252
|
+
'-fps_mode',
|
|
1253
|
+
'vfr',
|
|
1254
|
+
'-an',
|
|
1255
|
+
'-sn',
|
|
1256
|
+
'-f',
|
|
1257
|
+
'null',
|
|
1258
|
+
'-',
|
|
1259
|
+
];
|
|
1260
|
+
const timestamps = [];
|
|
1261
|
+
await runProcess({
|
|
1262
|
+
command: ffmpegPath,
|
|
1263
|
+
args,
|
|
1264
|
+
timeoutMs: Math.max(timeoutMs, FFMPEG_TIMEOUT_FALLBACK_MS),
|
|
1265
|
+
errorLabel: 'ffmpeg',
|
|
1266
|
+
onStderrLine: (line) => {
|
|
1267
|
+
const ts = parseShowinfoTimestamp(line);
|
|
1268
|
+
if (ts != null)
|
|
1269
|
+
timestamps.push(ts + segment.start);
|
|
1270
|
+
},
|
|
1271
|
+
});
|
|
1272
|
+
return timestamps;
|
|
1273
|
+
});
|
|
1274
|
+
const results = await runWithConcurrency(tasks, concurrency, onSegmentProgress ?? undefined);
|
|
1275
|
+
const merged = results.flat();
|
|
1276
|
+
merged.sort((a, b) => a - b);
|
|
1277
|
+
return merged;
|
|
1278
|
+
}
|
|
1279
|
+
async function hashFrameAtTimestamp({ ffmpegPath, inputPath, timestamp, timeoutMs, }) {
|
|
1280
|
+
const filter = 'scale=32:32,format=gray';
|
|
1281
|
+
const args = [
|
|
1282
|
+
'-hide_banner',
|
|
1283
|
+
'-ss',
|
|
1284
|
+
String(timestamp),
|
|
1285
|
+
'-i',
|
|
1286
|
+
inputPath,
|
|
1287
|
+
'-frames:v',
|
|
1288
|
+
'1',
|
|
1289
|
+
'-vf',
|
|
1290
|
+
filter,
|
|
1291
|
+
'-f',
|
|
1292
|
+
'rawvideo',
|
|
1293
|
+
'-pix_fmt',
|
|
1294
|
+
'gray',
|
|
1295
|
+
'-',
|
|
1296
|
+
];
|
|
1297
|
+
try {
|
|
1298
|
+
const buffer = await runProcessCaptureBuffer({
|
|
1299
|
+
command: ffmpegPath,
|
|
1300
|
+
args,
|
|
1301
|
+
timeoutMs,
|
|
1302
|
+
errorLabel: 'ffmpeg',
|
|
1303
|
+
});
|
|
1304
|
+
if (buffer.length < 1024)
|
|
1305
|
+
return null;
|
|
1306
|
+
const bytes = buffer.subarray(0, 1024);
|
|
1307
|
+
return buildAverageHash(bytes);
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
function buildAverageHash(pixels) {
|
|
1314
|
+
let sum = 0;
|
|
1315
|
+
for (const value of pixels)
|
|
1316
|
+
sum += value;
|
|
1317
|
+
const avg = sum / pixels.length;
|
|
1318
|
+
const bits = new Uint8Array(pixels.length);
|
|
1319
|
+
for (let i = 0; i < pixels.length; i += 1) {
|
|
1320
|
+
bits[i] = pixels[i] >= avg ? 1 : 0;
|
|
1321
|
+
}
|
|
1322
|
+
return bits;
|
|
1323
|
+
}
|
|
1324
|
+
function computeHashDistanceRatio(a, b) {
|
|
1325
|
+
const len = Math.min(a.length, b.length);
|
|
1326
|
+
let diff = 0;
|
|
1327
|
+
for (let i = 0; i < len; i += 1) {
|
|
1328
|
+
if (a[i] !== b[i])
|
|
1329
|
+
diff += 1;
|
|
1330
|
+
}
|
|
1331
|
+
return len === 0 ? 0 : diff / len;
|
|
1332
|
+
}
|
|
1333
|
+
async function probeVideoInfo({ ffprobePath, env, inputPath, timeoutMs, }) {
|
|
1334
|
+
const probeBin = ffprobePath ?? resolveExecutableInPath('ffprobe', env);
|
|
1335
|
+
if (!probeBin)
|
|
1336
|
+
return { durationSeconds: null, width: null, height: null };
|
|
1337
|
+
const args = ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', inputPath];
|
|
1338
|
+
try {
|
|
1339
|
+
const output = await runProcessCapture({
|
|
1340
|
+
command: probeBin,
|
|
1341
|
+
args,
|
|
1342
|
+
timeoutMs: Math.min(timeoutMs, 30_000),
|
|
1343
|
+
errorLabel: 'ffprobe',
|
|
1344
|
+
});
|
|
1345
|
+
const parsed = JSON.parse(output);
|
|
1346
|
+
let durationSeconds = null;
|
|
1347
|
+
let width = null;
|
|
1348
|
+
let height = null;
|
|
1349
|
+
for (const stream of parsed.streams ?? []) {
|
|
1350
|
+
if (stream.codec_type === 'video') {
|
|
1351
|
+
if (width == null && typeof stream.width === 'number')
|
|
1352
|
+
width = stream.width;
|
|
1353
|
+
if (height == null && typeof stream.height === 'number')
|
|
1354
|
+
height = stream.height;
|
|
1355
|
+
const duration = Number(stream.duration);
|
|
1356
|
+
if (Number.isFinite(duration) && duration > 0)
|
|
1357
|
+
durationSeconds = duration;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
if (durationSeconds == null) {
|
|
1361
|
+
const formatDuration = Number(parsed.format?.duration);
|
|
1362
|
+
if (Number.isFinite(formatDuration) && formatDuration > 0)
|
|
1363
|
+
durationSeconds = formatDuration;
|
|
1364
|
+
}
|
|
1365
|
+
return { durationSeconds, width, height };
|
|
1366
|
+
}
|
|
1367
|
+
catch {
|
|
1368
|
+
return { durationSeconds: null, width: null, height: null };
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
async function runProcess({ command, args, timeoutMs, errorLabel, onStderrLine, onStdoutLine, }) {
|
|
1372
|
+
await new Promise((resolve, reject) => {
|
|
1373
|
+
const { proc, handle } = spawnTracked(command, args, {
|
|
1374
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1375
|
+
label: errorLabel,
|
|
1376
|
+
kind: errorLabel,
|
|
1377
|
+
captureOutput: false,
|
|
1378
|
+
});
|
|
1379
|
+
let stderr = '';
|
|
1380
|
+
let stderrBuffer = '';
|
|
1381
|
+
let stdoutBuffer = '';
|
|
1382
|
+
const flushLine = (line) => {
|
|
1383
|
+
if (onStderrLine)
|
|
1384
|
+
onStderrLine(line, handle);
|
|
1385
|
+
handle?.appendOutput('stderr', line);
|
|
1386
|
+
if (stderr.length < 8192) {
|
|
1387
|
+
stderr += line;
|
|
1388
|
+
if (!line.endsWith('\n'))
|
|
1389
|
+
stderr += '\n';
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
if (proc.stderr) {
|
|
1393
|
+
proc.stderr.setEncoding('utf8');
|
|
1394
|
+
proc.stderr.on('data', (chunk) => {
|
|
1395
|
+
stderrBuffer += chunk;
|
|
1396
|
+
const lines = stderrBuffer.split(/\r?\n/);
|
|
1397
|
+
stderrBuffer = lines.pop() ?? '';
|
|
1398
|
+
for (const line of lines) {
|
|
1399
|
+
if (line)
|
|
1400
|
+
flushLine(line);
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
if (proc.stdout) {
|
|
1405
|
+
const handleStdoutLine = onStdoutLine ?? onStderrLine;
|
|
1406
|
+
if (handleStdoutLine) {
|
|
1407
|
+
proc.stdout.setEncoding('utf8');
|
|
1408
|
+
proc.stdout.on('data', (chunk) => {
|
|
1409
|
+
stdoutBuffer += chunk;
|
|
1410
|
+
const lines = stdoutBuffer.split(/\r?\n/);
|
|
1411
|
+
stdoutBuffer = lines.pop() ?? '';
|
|
1412
|
+
for (const line of lines) {
|
|
1413
|
+
if (!line)
|
|
1414
|
+
continue;
|
|
1415
|
+
handleStdoutLine(line, handle);
|
|
1416
|
+
handle?.appendOutput('stdout', line);
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
const timeout = setTimeout(() => {
|
|
1422
|
+
proc.kill('SIGKILL');
|
|
1423
|
+
reject(new Error(`${errorLabel} timed out`));
|
|
1424
|
+
}, timeoutMs);
|
|
1425
|
+
proc.on('error', (error) => {
|
|
1426
|
+
clearTimeout(timeout);
|
|
1427
|
+
reject(error);
|
|
1428
|
+
});
|
|
1429
|
+
proc.on('close', (code) => {
|
|
1430
|
+
clearTimeout(timeout);
|
|
1431
|
+
if (stderrBuffer.trim().length > 0) {
|
|
1432
|
+
flushLine(stderrBuffer.trim());
|
|
1433
|
+
}
|
|
1434
|
+
if (stdoutBuffer.trim().length > 0) {
|
|
1435
|
+
const handleStdoutLine = onStdoutLine ?? onStderrLine;
|
|
1436
|
+
if (handleStdoutLine)
|
|
1437
|
+
handleStdoutLine(stdoutBuffer.trim(), handle);
|
|
1438
|
+
handle?.appendOutput('stdout', stdoutBuffer.trim());
|
|
1439
|
+
}
|
|
1440
|
+
if (code === 0) {
|
|
1441
|
+
resolve();
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
const suffix = stderr.trim() ? `: ${stderr.trim()}` : '';
|
|
1445
|
+
reject(new Error(`${errorLabel} exited with code ${code}${suffix}`));
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
function applyMinDurationFilter(slides, minDurationSeconds, warnings) {
|
|
1450
|
+
if (minDurationSeconds <= 0)
|
|
1451
|
+
return slides;
|
|
1452
|
+
const filtered = [];
|
|
1453
|
+
let lastTimestamp = -Infinity;
|
|
1454
|
+
for (const slide of slides) {
|
|
1455
|
+
if (slide.timestamp - lastTimestamp >= minDurationSeconds) {
|
|
1456
|
+
filtered.push(slide);
|
|
1457
|
+
lastTimestamp = slide.timestamp;
|
|
1458
|
+
}
|
|
1459
|
+
else {
|
|
1460
|
+
void fs.rm(slide.imagePath, { force: true }).catch(() => { });
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
if (filtered.length < slides.length) {
|
|
1464
|
+
warnings.push(`Filtered ${slides.length - filtered.length} slides by min duration`);
|
|
1465
|
+
}
|
|
1466
|
+
return filtered.map((slide, index) => ({ ...slide, index: index + 1 }));
|
|
1467
|
+
}
|
|
1468
|
+
function mergeTimestamps(sceneTimestamps, intervalTimestamps, minDurationSeconds) {
|
|
1469
|
+
const merged = [...sceneTimestamps, ...intervalTimestamps].filter((value) => Number.isFinite(value));
|
|
1470
|
+
merged.sort((a, b) => a - b);
|
|
1471
|
+
if (merged.length === 0)
|
|
1472
|
+
return [];
|
|
1473
|
+
const result = [];
|
|
1474
|
+
const minGap = Math.max(0.1, minDurationSeconds * 0.5);
|
|
1475
|
+
for (const ts of merged) {
|
|
1476
|
+
if (result.length === 0 || ts - result[result.length - 1] >= minGap) {
|
|
1477
|
+
result.push(ts);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
return result;
|
|
1481
|
+
}
|
|
1482
|
+
function filterTimestampsByMinDuration(timestamps, minDurationSeconds) {
|
|
1483
|
+
if (minDurationSeconds <= 0)
|
|
1484
|
+
return timestamps.slice();
|
|
1485
|
+
const sorted = timestamps
|
|
1486
|
+
.filter((value) => Number.isFinite(value))
|
|
1487
|
+
.slice()
|
|
1488
|
+
.sort((a, b) => a - b);
|
|
1489
|
+
const filtered = [];
|
|
1490
|
+
let lastTimestamp = -Infinity;
|
|
1491
|
+
for (const ts of sorted) {
|
|
1492
|
+
if (ts - lastTimestamp >= minDurationSeconds) {
|
|
1493
|
+
filtered.push(ts);
|
|
1494
|
+
lastTimestamp = ts;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return filtered;
|
|
1498
|
+
}
|
|
1499
|
+
function buildSceneSegments(sceneTimestamps, durationSeconds) {
|
|
1500
|
+
const sorted = sceneTimestamps
|
|
1501
|
+
.filter((value) => Number.isFinite(value) && value >= 0)
|
|
1502
|
+
.slice()
|
|
1503
|
+
.sort((a, b) => a - b);
|
|
1504
|
+
const deduped = [];
|
|
1505
|
+
for (const ts of sorted) {
|
|
1506
|
+
if (deduped.length === 0 || ts - deduped[deduped.length - 1] > 0.05) {
|
|
1507
|
+
deduped.push(ts);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
const starts = [0, ...deduped];
|
|
1511
|
+
const ends = [...deduped, durationSeconds];
|
|
1512
|
+
const segments = [];
|
|
1513
|
+
for (let i = 0; i < starts.length; i += 1) {
|
|
1514
|
+
const start = starts[i];
|
|
1515
|
+
const rawEnd = ends[i];
|
|
1516
|
+
const end = typeof rawEnd === 'number' && Number.isFinite(rawEnd) && rawEnd > start ? rawEnd : null;
|
|
1517
|
+
segments.push({ start, end });
|
|
1518
|
+
}
|
|
1519
|
+
return segments;
|
|
1520
|
+
}
|
|
1521
|
+
function findSceneSegment(segments, timestamp) {
|
|
1522
|
+
if (segments.length === 0)
|
|
1523
|
+
return null;
|
|
1524
|
+
for (const segment of segments) {
|
|
1525
|
+
if (timestamp >= segment.start && (segment.end == null || timestamp < segment.end)) {
|
|
1526
|
+
return segment;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
return segments[segments.length - 1] ?? null;
|
|
1530
|
+
}
|
|
1531
|
+
function adjustTimestampWithinSegment(timestamp, segment) {
|
|
1532
|
+
if (!segment)
|
|
1533
|
+
return timestamp;
|
|
1534
|
+
const start = Math.max(0, segment.start);
|
|
1535
|
+
const end = segment.end;
|
|
1536
|
+
if (end == null || !Number.isFinite(end) || end <= start) {
|
|
1537
|
+
return Math.max(timestamp, start);
|
|
1538
|
+
}
|
|
1539
|
+
const duration = Math.max(0, end - start);
|
|
1540
|
+
const padding = Math.min(1.5, Math.max(0.2, duration * 0.08));
|
|
1541
|
+
if (duration <= padding * 2) {
|
|
1542
|
+
return start + duration * 0.5;
|
|
1543
|
+
}
|
|
1544
|
+
return clamp(timestamp, start + padding, end - padding);
|
|
1545
|
+
}
|
|
1546
|
+
function selectTimestampTargets({ targets, sceneTimestamps, minDurationSeconds, intervalSeconds, }) {
|
|
1547
|
+
const targetList = targets
|
|
1548
|
+
.filter((value) => Number.isFinite(value))
|
|
1549
|
+
.slice()
|
|
1550
|
+
.sort((a, b) => a - b);
|
|
1551
|
+
if (targetList.length === 0)
|
|
1552
|
+
return [];
|
|
1553
|
+
const sceneList = filterTimestampsByMinDuration(sceneTimestamps, Math.max(0.1, minDurationSeconds * 0.25));
|
|
1554
|
+
const windowSeconds = Math.max(2, Math.min(10, intervalSeconds * 0.35));
|
|
1555
|
+
const picked = [];
|
|
1556
|
+
let lastPicked = -Infinity;
|
|
1557
|
+
let sceneIndex = 0;
|
|
1558
|
+
for (const target of targetList) {
|
|
1559
|
+
while (sceneIndex < sceneList.length && sceneList[sceneIndex] < target - windowSeconds) {
|
|
1560
|
+
sceneIndex += 1;
|
|
1561
|
+
}
|
|
1562
|
+
let best = null;
|
|
1563
|
+
let bestDiff = Number.POSITIVE_INFINITY;
|
|
1564
|
+
for (let idx = sceneIndex; idx < sceneList.length; idx += 1) {
|
|
1565
|
+
const candidate = sceneList[idx];
|
|
1566
|
+
if (candidate > target + windowSeconds)
|
|
1567
|
+
break;
|
|
1568
|
+
const diff = Math.abs(candidate - target);
|
|
1569
|
+
if (diff < bestDiff) {
|
|
1570
|
+
best = candidate;
|
|
1571
|
+
bestDiff = diff;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
const candidate = best ?? target;
|
|
1575
|
+
const chosen = candidate - lastPicked >= minDurationSeconds ? candidate : target;
|
|
1576
|
+
picked.push(chosen);
|
|
1577
|
+
lastPicked = chosen;
|
|
1578
|
+
}
|
|
1579
|
+
return picked;
|
|
1580
|
+
}
|
|
1581
|
+
function buildIntervalTimestamps({ durationSeconds, minDurationSeconds, maxSlides, }) {
|
|
1582
|
+
if (!durationSeconds || durationSeconds <= 0)
|
|
1583
|
+
return null;
|
|
1584
|
+
const maxCount = Math.max(1, Math.floor(maxSlides));
|
|
1585
|
+
const targetCount = Math.min(maxCount, clamp(Math.round(durationSeconds / 180), 6, 20));
|
|
1586
|
+
const intervalSeconds = Math.max(minDurationSeconds, durationSeconds / targetCount);
|
|
1587
|
+
if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0)
|
|
1588
|
+
return null;
|
|
1589
|
+
const timestamps = [];
|
|
1590
|
+
for (let t = 0; t < durationSeconds; t += intervalSeconds) {
|
|
1591
|
+
timestamps.push(t);
|
|
1592
|
+
}
|
|
1593
|
+
return { timestamps, intervalSeconds };
|
|
1594
|
+
}
|
|
1595
|
+
async function runProcessCapture({ command, args, timeoutMs, errorLabel, }) {
|
|
1596
|
+
return new Promise((resolve, reject) => {
|
|
1597
|
+
const { proc, handle } = spawnTracked(command, args, {
|
|
1598
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1599
|
+
label: errorLabel,
|
|
1600
|
+
kind: errorLabel,
|
|
1601
|
+
captureOutput: false,
|
|
1602
|
+
});
|
|
1603
|
+
let stdout = '';
|
|
1604
|
+
let stderr = '';
|
|
1605
|
+
let stdoutBuffer = '';
|
|
1606
|
+
let stderrBuffer = '';
|
|
1607
|
+
const timeout = setTimeout(() => {
|
|
1608
|
+
proc.kill('SIGKILL');
|
|
1609
|
+
reject(new Error(`${errorLabel} timed out`));
|
|
1610
|
+
}, timeoutMs);
|
|
1611
|
+
if (proc.stdout) {
|
|
1612
|
+
proc.stdout.setEncoding('utf8');
|
|
1613
|
+
proc.stdout.on('data', (chunk) => {
|
|
1614
|
+
stdout += chunk;
|
|
1615
|
+
stdoutBuffer += chunk;
|
|
1616
|
+
const lines = stdoutBuffer.split(/\r?\n/);
|
|
1617
|
+
stdoutBuffer = lines.pop() ?? '';
|
|
1618
|
+
for (const line of lines) {
|
|
1619
|
+
if (line)
|
|
1620
|
+
handle?.appendOutput('stdout', line);
|
|
1621
|
+
}
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
if (proc.stderr) {
|
|
1625
|
+
proc.stderr.setEncoding('utf8');
|
|
1626
|
+
proc.stderr.on('data', (chunk) => {
|
|
1627
|
+
if (stderr.length < 8192) {
|
|
1628
|
+
stderr += chunk;
|
|
1629
|
+
}
|
|
1630
|
+
stderrBuffer += chunk;
|
|
1631
|
+
const lines = stderrBuffer.split(/\r?\n/);
|
|
1632
|
+
stderrBuffer = lines.pop() ?? '';
|
|
1633
|
+
for (const line of lines) {
|
|
1634
|
+
if (line)
|
|
1635
|
+
handle?.appendOutput('stderr', line);
|
|
1636
|
+
}
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
proc.on('error', (error) => {
|
|
1640
|
+
clearTimeout(timeout);
|
|
1641
|
+
reject(error);
|
|
1642
|
+
});
|
|
1643
|
+
proc.on('close', (code) => {
|
|
1644
|
+
clearTimeout(timeout);
|
|
1645
|
+
if (stdoutBuffer.trim())
|
|
1646
|
+
handle?.appendOutput('stdout', stdoutBuffer.trim());
|
|
1647
|
+
if (stderrBuffer.trim())
|
|
1648
|
+
handle?.appendOutput('stderr', stderrBuffer.trim());
|
|
1649
|
+
if (code === 0) {
|
|
1650
|
+
resolve(stdout);
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
const suffix = stderr.trim() ? `: ${stderr.trim()}` : '';
|
|
1654
|
+
reject(new Error(`${errorLabel} exited with code ${code}${suffix}`));
|
|
1655
|
+
});
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
async function runProcessCaptureBuffer({ command, args, timeoutMs, errorLabel, }) {
|
|
1659
|
+
return new Promise((resolve, reject) => {
|
|
1660
|
+
const { proc, handle } = spawnTracked(command, args, {
|
|
1661
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1662
|
+
label: errorLabel,
|
|
1663
|
+
kind: errorLabel,
|
|
1664
|
+
captureOutput: false,
|
|
1665
|
+
});
|
|
1666
|
+
const chunks = [];
|
|
1667
|
+
let stderr = '';
|
|
1668
|
+
let stderrBuffer = '';
|
|
1669
|
+
const timeout = setTimeout(() => {
|
|
1670
|
+
proc.kill('SIGKILL');
|
|
1671
|
+
reject(new Error(`${errorLabel} timed out`));
|
|
1672
|
+
}, timeoutMs);
|
|
1673
|
+
if (proc.stdout) {
|
|
1674
|
+
proc.stdout.on('data', (chunk) => {
|
|
1675
|
+
chunks.push(chunk);
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
if (proc.stderr) {
|
|
1679
|
+
proc.stderr.setEncoding('utf8');
|
|
1680
|
+
proc.stderr.on('data', (chunk) => {
|
|
1681
|
+
if (stderr.length < 8192) {
|
|
1682
|
+
stderr += chunk;
|
|
1683
|
+
}
|
|
1684
|
+
stderrBuffer += chunk;
|
|
1685
|
+
const lines = stderrBuffer.split(/\r?\n/);
|
|
1686
|
+
stderrBuffer = lines.pop() ?? '';
|
|
1687
|
+
for (const line of lines) {
|
|
1688
|
+
if (line)
|
|
1689
|
+
handle?.appendOutput('stderr', line);
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
proc.on('error', (error) => {
|
|
1694
|
+
clearTimeout(timeout);
|
|
1695
|
+
reject(error);
|
|
1696
|
+
});
|
|
1697
|
+
proc.on('close', (code) => {
|
|
1698
|
+
clearTimeout(timeout);
|
|
1699
|
+
if (stderrBuffer.trim())
|
|
1700
|
+
handle?.appendOutput('stderr', stderrBuffer.trim());
|
|
1701
|
+
if (code === 0) {
|
|
1702
|
+
resolve(Buffer.concat(chunks));
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
const suffix = stderr.trim() ? `: ${stderr.trim()}` : '';
|
|
1706
|
+
reject(new Error(`${errorLabel} exited with code ${code}${suffix}`));
|
|
1707
|
+
});
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
function applyMaxSlidesFilter(slides, maxSlides, warnings) {
|
|
1711
|
+
if (maxSlides <= 0 || slides.length <= maxSlides)
|
|
1712
|
+
return slides;
|
|
1713
|
+
const kept = slides.slice(0, maxSlides);
|
|
1714
|
+
const removed = slides.slice(maxSlides);
|
|
1715
|
+
for (const slide of removed) {
|
|
1716
|
+
if (slide.imagePath) {
|
|
1717
|
+
void fs.rm(slide.imagePath, { force: true }).catch(() => { });
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
warnings.push(`Trimmed slides to max ${maxSlides}`);
|
|
1721
|
+
return kept.map((slide, index) => ({ ...slide, index: index + 1 }));
|
|
1722
|
+
}
|
|
1723
|
+
async function renameSlidesWithTimestamps(slides, slidesDir) {
|
|
1724
|
+
const renamed = [];
|
|
1725
|
+
for (const slide of slides) {
|
|
1726
|
+
const timestampLabel = slide.timestamp.toFixed(2);
|
|
1727
|
+
const filename = `slide_${slide.index.toString().padStart(4, '0')}_${timestampLabel}s.png`;
|
|
1728
|
+
const nextPath = path.join(slidesDir, filename);
|
|
1729
|
+
if (slide.imagePath !== nextPath) {
|
|
1730
|
+
await fs.rename(slide.imagePath, nextPath).catch(async () => {
|
|
1731
|
+
await fs.copyFile(slide.imagePath, nextPath);
|
|
1732
|
+
await fs.rm(slide.imagePath, { force: true });
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
renamed.push({ ...slide, imagePath: nextPath });
|
|
1736
|
+
}
|
|
1737
|
+
return renamed;
|
|
1738
|
+
}
|
|
1739
|
+
async function withSlidesLock(key, fn, onWait) {
|
|
1740
|
+
const previous = slidesLocks.get(key) ?? null;
|
|
1741
|
+
if (previous && onWait)
|
|
1742
|
+
onWait();
|
|
1743
|
+
let release = () => { };
|
|
1744
|
+
const current = new Promise((resolve) => {
|
|
1745
|
+
release = resolve;
|
|
1746
|
+
});
|
|
1747
|
+
slidesLocks.set(key, current);
|
|
1748
|
+
await (previous ?? Promise.resolve());
|
|
1749
|
+
try {
|
|
1750
|
+
return await fn();
|
|
1751
|
+
}
|
|
1752
|
+
finally {
|
|
1753
|
+
release();
|
|
1754
|
+
if (slidesLocks.get(key) === current) {
|
|
1755
|
+
slidesLocks.delete(key);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
async function runWithConcurrency(tasks, workers, onProgress) {
|
|
1760
|
+
if (tasks.length === 0)
|
|
1761
|
+
return [];
|
|
1762
|
+
const concurrency = Math.max(1, Math.min(16, Math.round(workers)));
|
|
1763
|
+
const results = new Array(tasks.length);
|
|
1764
|
+
const total = tasks.length;
|
|
1765
|
+
let completed = 0;
|
|
1766
|
+
let nextIndex = 0;
|
|
1767
|
+
const worker = async () => {
|
|
1768
|
+
while (true) {
|
|
1769
|
+
const current = nextIndex;
|
|
1770
|
+
if (current >= tasks.length)
|
|
1771
|
+
return;
|
|
1772
|
+
nextIndex += 1;
|
|
1773
|
+
try {
|
|
1774
|
+
results[current] = await tasks[current]();
|
|
1775
|
+
}
|
|
1776
|
+
finally {
|
|
1777
|
+
completed += 1;
|
|
1778
|
+
onProgress?.(completed, total);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
};
|
|
1782
|
+
const runners = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker());
|
|
1783
|
+
await Promise.all(runners);
|
|
1784
|
+
return results;
|
|
1785
|
+
}
|
|
1786
|
+
async function runOcrOnSlides(slides, tesseractPath, workers, onProgress) {
|
|
1787
|
+
const tasks = slides.map((slide) => async () => {
|
|
1788
|
+
try {
|
|
1789
|
+
const text = await runTesseract(tesseractPath, slide.imagePath);
|
|
1790
|
+
const cleaned = cleanOcrText(text);
|
|
1791
|
+
return {
|
|
1792
|
+
...slide,
|
|
1793
|
+
ocrText: cleaned,
|
|
1794
|
+
ocrConfidence: estimateOcrConfidence(cleaned),
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
catch {
|
|
1798
|
+
return { ...slide, ocrText: '', ocrConfidence: 0 };
|
|
1799
|
+
}
|
|
1800
|
+
});
|
|
1801
|
+
const results = await runWithConcurrency(tasks, workers, onProgress ?? undefined);
|
|
1802
|
+
return results.sort((a, b) => a.index - b.index);
|
|
1803
|
+
}
|
|
1804
|
+
async function runTesseract(tesseractPath, imagePath) {
|
|
1805
|
+
return new Promise((resolve, reject) => {
|
|
1806
|
+
const args = [imagePath, 'stdout', '--oem', '3', '--psm', '6'];
|
|
1807
|
+
const { proc, handle } = spawnTracked(tesseractPath, args, {
|
|
1808
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1809
|
+
label: 'tesseract',
|
|
1810
|
+
kind: 'tesseract',
|
|
1811
|
+
captureOutput: false,
|
|
1812
|
+
});
|
|
1813
|
+
let stdout = '';
|
|
1814
|
+
let stderr = '';
|
|
1815
|
+
let stderrBuffer = '';
|
|
1816
|
+
const timeout = setTimeout(() => {
|
|
1817
|
+
proc.kill('SIGKILL');
|
|
1818
|
+
reject(new Error('tesseract timed out'));
|
|
1819
|
+
}, TESSERACT_TIMEOUT_MS);
|
|
1820
|
+
if (proc.stdout) {
|
|
1821
|
+
proc.stdout.setEncoding('utf8');
|
|
1822
|
+
proc.stdout.on('data', (chunk) => {
|
|
1823
|
+
stdout += chunk;
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
if (proc.stderr) {
|
|
1827
|
+
proc.stderr.setEncoding('utf8');
|
|
1828
|
+
proc.stderr.on('data', (chunk) => {
|
|
1829
|
+
if (stderr.length < 8192) {
|
|
1830
|
+
stderr += chunk;
|
|
1831
|
+
}
|
|
1832
|
+
stderrBuffer += chunk;
|
|
1833
|
+
const lines = stderrBuffer.split(/\r?\n/);
|
|
1834
|
+
stderrBuffer = lines.pop() ?? '';
|
|
1835
|
+
for (const line of lines) {
|
|
1836
|
+
if (line)
|
|
1837
|
+
handle?.appendOutput('stderr', line);
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
proc.on('error', (error) => {
|
|
1842
|
+
clearTimeout(timeout);
|
|
1843
|
+
reject(error);
|
|
1844
|
+
});
|
|
1845
|
+
proc.on('close', (code) => {
|
|
1846
|
+
clearTimeout(timeout);
|
|
1847
|
+
if (stderrBuffer.trim())
|
|
1848
|
+
handle?.appendOutput('stderr', stderrBuffer.trim());
|
|
1849
|
+
if (code === 0) {
|
|
1850
|
+
resolve(stdout);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
const suffix = stderr.trim() ? `: ${stderr.trim()}` : '';
|
|
1854
|
+
reject(new Error(`tesseract exited with code ${code}${suffix}`));
|
|
1855
|
+
});
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
function cleanOcrText(text) {
|
|
1859
|
+
const lines = text
|
|
1860
|
+
.split(/\r?\n/)
|
|
1861
|
+
.map((line) => line.trim())
|
|
1862
|
+
.filter((line) => line.length >= 2)
|
|
1863
|
+
.filter((line) => !(line.length > 20 && !line.includes(' ')))
|
|
1864
|
+
.filter((line) => /[a-z0-9]/i.test(line));
|
|
1865
|
+
return lines.join('\n');
|
|
1866
|
+
}
|
|
1867
|
+
function estimateOcrConfidence(text) {
|
|
1868
|
+
if (!text)
|
|
1869
|
+
return 0;
|
|
1870
|
+
const total = text.length;
|
|
1871
|
+
if (total === 0)
|
|
1872
|
+
return 0;
|
|
1873
|
+
const alnum = Array.from(text).filter((char) => /[a-z0-9]/i.test(char)).length;
|
|
1874
|
+
return Math.min(1, alnum / total);
|
|
1875
|
+
}
|
|
1876
|
+
async function writeSlidesJson(result, slidesDir) {
|
|
1877
|
+
const slidesDirId = result.slidesDirId ?? buildSlidesDirId(slidesDir);
|
|
1878
|
+
const payload = {
|
|
1879
|
+
sourceUrl: result.sourceUrl,
|
|
1880
|
+
sourceKind: result.sourceKind,
|
|
1881
|
+
sourceId: result.sourceId,
|
|
1882
|
+
slidesDir,
|
|
1883
|
+
slidesDirId,
|
|
1884
|
+
sceneThreshold: result.sceneThreshold,
|
|
1885
|
+
autoTuneThreshold: result.autoTuneThreshold,
|
|
1886
|
+
autoTune: result.autoTune,
|
|
1887
|
+
maxSlides: result.maxSlides,
|
|
1888
|
+
minSlideDuration: result.minSlideDuration,
|
|
1889
|
+
ocrRequested: result.ocrRequested,
|
|
1890
|
+
ocrAvailable: result.ocrAvailable,
|
|
1891
|
+
slideCount: result.slides.length,
|
|
1892
|
+
warnings: result.warnings,
|
|
1893
|
+
slides: result.slides.map((slide) => ({
|
|
1894
|
+
...slide,
|
|
1895
|
+
imagePath: serializeSlideImagePath(slidesDir, slide.imagePath),
|
|
1896
|
+
})),
|
|
1897
|
+
};
|
|
1898
|
+
await fs.writeFile(path.join(slidesDir, 'slides.json'), JSON.stringify(payload, null, 2), 'utf8');
|
|
1899
|
+
}
|
|
1900
|
+
function buildDirectSourceId(url) {
|
|
1901
|
+
const parsed = (() => {
|
|
1902
|
+
try {
|
|
1903
|
+
return new URL(url);
|
|
1904
|
+
}
|
|
1905
|
+
catch {
|
|
1906
|
+
return null;
|
|
1907
|
+
}
|
|
1908
|
+
})();
|
|
1909
|
+
const hostSlug = resolveHostSlug(parsed);
|
|
1910
|
+
const rawName = parsed ? path.basename(parsed.pathname) : 'video';
|
|
1911
|
+
const base = rawName.replace(/\.[a-z0-9]+$/i, '').trim() || 'video';
|
|
1912
|
+
const slug = toSlug(base);
|
|
1913
|
+
const combined = [hostSlug, slug].filter(Boolean).join('-');
|
|
1914
|
+
const hash = createHash('sha1').update(url).digest('hex').slice(0, 8);
|
|
1915
|
+
return combined ? `${combined}-${hash}` : `video-${hash}`;
|
|
1916
|
+
}
|
|
1917
|
+
function buildYoutubeSourceId(videoId) {
|
|
1918
|
+
return `youtube-${videoId}`;
|
|
1919
|
+
}
|
|
1920
|
+
function resolveHostSlug(parsed) {
|
|
1921
|
+
if (!parsed?.hostname)
|
|
1922
|
+
return null;
|
|
1923
|
+
const host = parsed.hostname.toLowerCase();
|
|
1924
|
+
if (host.includes('youtube.com') || host === 'youtu.be' || host.includes('youtu.be')) {
|
|
1925
|
+
return 'youtube';
|
|
1926
|
+
}
|
|
1927
|
+
const slug = toSlug(host);
|
|
1928
|
+
return slug || null;
|
|
1929
|
+
}
|
|
1930
|
+
function toSlug(value) {
|
|
1931
|
+
const normalized = value
|
|
1932
|
+
.toLowerCase()
|
|
1933
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1934
|
+
.replace(/^-+|-+$/g, '');
|
|
1935
|
+
if (!normalized)
|
|
1936
|
+
return '';
|
|
1937
|
+
const max = 64;
|
|
1938
|
+
if (normalized.length <= max)
|
|
1939
|
+
return normalized;
|
|
1940
|
+
return normalized.slice(0, max).replace(/-+$/g, '');
|
|
1941
|
+
}
|
|
1942
|
+
//# sourceMappingURL=extract.js.map
|