@thunderkiller/video-clipper 1.2.0 → 1.4.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 +19 -0
- package/CONTRIBUTING.md +100 -0
- package/LICENSE +15 -0
- package/commitlint.config.js +25 -0
- package/package.json +3 -1
- package/.github/workflows/ci.yml +0 -42
- package/.github/workflows/release.yml +0 -76
- package/.husky/pre-commit +0 -3
- package/.prettierignore +0 -6
- package/.prettierrc +0 -7
- package/.releaserc.json +0 -21
- package/AGENTS.md +0 -122
- package/docs/free-models.md +0 -78
- package/docs/plan.md +0 -442
- package/docs/refactorPhases.md +0 -105
- package/docs/yt-downloader.md +0 -440
- package/requirements.txt +0 -5
- package/scripts/detect_events.py +0 -81
- package/scripts/detect_events_whisper.py +0 -101
- package/scripts/transcribe_whisper.py +0 -70
- package/src/cli.ts +0 -186
- package/src/config/env.ts +0 -18
- package/src/config/index.ts +0 -2
- package/src/index.ts +0 -46
- package/src/pipeline/runner.ts +0 -147
- package/src/pipeline/stages/audioProcessor.ts +0 -127
- package/src/pipeline/stages/clipExporter.ts +0 -76
- package/src/pipeline/stages/segmentAnalyzer.ts +0 -72
- package/src/pipeline/stages/segmentSelector.ts +0 -39
- package/src/pipeline/stages/videoResolver.ts +0 -44
- package/src/services/audioAnalyzers/base.ts +0 -32
- package/src/services/audioAnalyzers/factory.ts +0 -69
- package/src/services/audioAnalyzers/gemini.ts +0 -136
- package/src/services/audioAnalyzers/index.ts +0 -6
- package/src/services/audioAnalyzers/whisper.ts +0 -80
- package/src/services/audioAnalyzers/yamnet.ts +0 -54
- package/src/services/audioDownloader/index.ts +0 -102
- package/src/services/chunkBuilder/index.ts +0 -82
- package/src/services/clipGenerator/index.ts +0 -210
- package/src/services/clipRefiner/index.ts +0 -141
- package/src/services/eventDetector/index.ts +0 -68
- package/src/services/llmAnalyzer/LLMAnalyzer.ts +0 -98
- package/src/services/llmAnalyzer/index.ts +0 -231
- package/src/services/metadataExtractor/index.ts +0 -83
- package/src/services/segmentRanker/index.ts +0 -88
- package/src/services/signalMerger/index.ts +0 -53
- package/src/services/transcriptAnalyzers/base.ts +0 -26
- package/src/services/transcriptAnalyzers/factory.ts +0 -66
- package/src/services/transcriptAnalyzers/gemini.ts +0 -24
- package/src/services/transcriptAnalyzers/index.ts +0 -6
- package/src/services/transcriptAnalyzers/whisper.ts +0 -68
- package/src/services/transcriptAnalyzers/ytdlp.ts +0 -19
- package/src/services/transcriptDetector/index.ts +0 -122
- package/src/services/transcriptFetcher/index.ts +0 -147
- package/src/services/urlParser/index.ts +0 -52
- package/src/services/videoDownloader/index.ts +0 -268
- package/src/types/analyzer.ts +0 -23
- package/src/types/audio.ts +0 -19
- package/src/types/cache.ts +0 -8
- package/src/types/cli.ts +0 -22
- package/src/types/config.ts +0 -151
- package/src/types/downloader.ts +0 -15
- package/src/types/factory.ts +0 -3
- package/src/types/index.ts +0 -40
- package/src/types/pipeline.ts +0 -60
- package/src/types/segment.ts +0 -43
- package/src/types/transcript.ts +0 -22
- package/src/types/video.ts +0 -18
- package/src/utils/cache.ts +0 -224
- package/src/utils/chunker.ts +0 -60
- package/src/utils/dumper.ts +0 -41
- package/src/utils/format.ts +0 -10
- package/src/utils/logger.ts +0 -17
- package/src/utils/modelFactory.ts +0 -71
- package/src/utils/redactConfig.ts +0 -23
- package/src/utils/sliceAudio.ts +0 -35
- package/test-trigger.txt +0 -1
- package/tests/analyzerFactory.test.ts +0 -146
- package/tests/audioEventDetector.test.ts +0 -69
- package/tests/cache.test.ts +0 -203
- package/tests/chunkBuilder.test.ts +0 -146
- package/tests/chunker.test.ts +0 -95
- package/tests/eventDetector.test.ts +0 -103
- package/tests/llmAnalyzer.test.ts +0 -283
- package/tests/segmentRanker.test.ts +0 -133
- package/tests/setup.ts +0 -48
- package/tests/signalMerger.test.ts +0 -197
- package/tests/transcriptDetector.test.ts +0 -150
- package/tests/transcriptFetcher.test.ts +0 -179
- package/tests/urlParser.test.ts +0 -70
- package/tsconfig.json +0 -16
- package/tsconfig.test.json +0 -8
- package/vitest.config.ts +0 -8
package/src/types/config.ts
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
|
|
3
|
-
const LLM_PROVIDERS = [
|
|
4
|
-
'openai',
|
|
5
|
-
'anthropic',
|
|
6
|
-
'google',
|
|
7
|
-
'xai',
|
|
8
|
-
'mistral',
|
|
9
|
-
'groq',
|
|
10
|
-
'zai',
|
|
11
|
-
'openrouter',
|
|
12
|
-
'custom',
|
|
13
|
-
] as const;
|
|
14
|
-
|
|
15
|
-
export type LLMProvider = (typeof LLM_PROVIDERS)[number];
|
|
16
|
-
|
|
17
|
-
const PROVIDER_KEY_MAP: Record<LLMProvider, string> = {
|
|
18
|
-
openai: 'OPENAI_API_KEY',
|
|
19
|
-
anthropic: 'ANTHROPIC_API_KEY',
|
|
20
|
-
google: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
|
21
|
-
xai: 'XAI_API_KEY',
|
|
22
|
-
mistral: 'MISTRAL_API_KEY',
|
|
23
|
-
groq: 'GROQ_API_KEY',
|
|
24
|
-
zai: 'ZAI_API_KEY',
|
|
25
|
-
openrouter: 'OPENROUTER_API_KEY',
|
|
26
|
-
custom: 'CUSTOM_OPENAI_API_KEY',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
export const ConfigSchema = z
|
|
30
|
-
.object({
|
|
31
|
-
LLM_PROVIDER: z.enum(LLM_PROVIDERS).default('openai'),
|
|
32
|
-
|
|
33
|
-
OPENAI_API_KEY: z.string().optional(),
|
|
34
|
-
ANTHROPIC_API_KEY: z.string().optional(),
|
|
35
|
-
GOOGLE_GENERATIVE_AI_API_KEY: z.string().optional(),
|
|
36
|
-
XAI_API_KEY: z.string().optional(),
|
|
37
|
-
MISTRAL_API_KEY: z.string().optional(),
|
|
38
|
-
GROQ_API_KEY: z.string().optional(),
|
|
39
|
-
ZAI_API_KEY: z.string().optional(),
|
|
40
|
-
OPENROUTER_API_KEY: z.string().optional(),
|
|
41
|
-
CUSTOM_OPENAI_API_KEY: z.string().optional(),
|
|
42
|
-
CUSTOM_OPENAI_BASE_URL: z.string().url().optional(),
|
|
43
|
-
|
|
44
|
-
SCORE_THRESHOLD: z.coerce.number().min(1).max(10).default(7),
|
|
45
|
-
TOP_N_SEGMENTS: z.coerce.number().min(1).default(10),
|
|
46
|
-
CHUNK_LENGTH_SEC: z.coerce.number().min(10).default(120),
|
|
47
|
-
CHUNK_OVERLAP_SEC: z.coerce.number().min(0).default(20),
|
|
48
|
-
MICRO_BLOCK_SEC: z.coerce.number().min(5).default(15),
|
|
49
|
-
LLM_MODEL: z.string().default('gpt-4o'),
|
|
50
|
-
LLM_MAX_RETRIES: z.coerce.number().min(0).default(3),
|
|
51
|
-
DOWNLOAD_DIR: z.string().default('downloads/'),
|
|
52
|
-
OUTPUT_DIR: z.string().default('outputs/'),
|
|
53
|
-
CACHE_DIR: z.string().default('outputs/cache'),
|
|
54
|
-
DUMP_OUTPUTS: z.coerce.boolean().default(true),
|
|
55
|
-
MAX_CHUNKS: z.coerce.number().min(1).optional(),
|
|
56
|
-
LLM_CONCURRENCY: z.coerce.number().min(1).default(3),
|
|
57
|
-
CLIP_CONCURRENCY: z.coerce.number().min(1).default(1),
|
|
58
|
-
LLM_SYSTEM_PROMPT: z.string().optional(),
|
|
59
|
-
AUDIO_GEMINI_MODEL: z.string().default('gemini-2.5-flash'),
|
|
60
|
-
AUDIO_EXTRA_INSTRUCTIONS: z.string().optional(),
|
|
61
|
-
DOWNLOAD_SECTIONS_MODE: z.union([z.literal('all'), z.number().int().positive()]).default('all'),
|
|
62
|
-
FFMPEG_PATH: z.string().optional(),
|
|
63
|
-
FFPROBE_PATH: z.string().optional(),
|
|
64
|
-
FFMPEG_PRESET: z
|
|
65
|
-
.enum(['ultrafast', 'superfast', 'veryfast', 'fast', 'medium', 'slow', 'slower'])
|
|
66
|
-
.default('fast'),
|
|
67
|
-
TIMESTAMP_OFFSET_SECONDS: z.coerce.number().default(0),
|
|
68
|
-
TRANSCRIPT_PROVIDER: z
|
|
69
|
-
.string()
|
|
70
|
-
.default('ytdlp')
|
|
71
|
-
.refine(
|
|
72
|
-
(v) => {
|
|
73
|
-
const parts = v
|
|
74
|
-
.split(',')
|
|
75
|
-
.map((s) => s.trim())
|
|
76
|
-
.filter(Boolean);
|
|
77
|
-
return parts.length > 0 && parts.every((p) => ['ytdlp', 'whisper', 'gemini'].includes(p));
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
message:
|
|
81
|
-
'TRANSCRIPT_PROVIDER must be a comma-separated list of: ytdlp, whisper, gemini (e.g. "ytdlp")',
|
|
82
|
-
},
|
|
83
|
-
),
|
|
84
|
-
AUDIO_DETECTION_ENABLED: z.coerce.boolean().default(true),
|
|
85
|
-
AUDIO_PROVIDER: z
|
|
86
|
-
.string()
|
|
87
|
-
.default('gemini,whisper')
|
|
88
|
-
.refine(
|
|
89
|
-
(v) => {
|
|
90
|
-
const legacy = v.trim() === 'both';
|
|
91
|
-
if (legacy) return true;
|
|
92
|
-
const parts = v
|
|
93
|
-
.split(',')
|
|
94
|
-
.map((s) => s.trim())
|
|
95
|
-
.filter(Boolean);
|
|
96
|
-
return (
|
|
97
|
-
parts.length > 0 && parts.every((p) => ['gemini', 'whisper', 'yamnet'].includes(p))
|
|
98
|
-
);
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
message:
|
|
102
|
-
'AUDIO_PROVIDER must be a comma-separated list of: gemini, whisper, yamnet (e.g. "gemini,whisper")',
|
|
103
|
-
},
|
|
104
|
-
),
|
|
105
|
-
AUDIO_WHISPER_MODEL: z.enum(['tiny', 'base', 'small', 'medium', 'large-v3']).default('medium'),
|
|
106
|
-
AUDIO_CONFIDENCE_THRESHOLD: z.coerce.number().min(0).max(1).default(0.3),
|
|
107
|
-
AUDIO_CLIP_PRE_ROLL: z.coerce.number().min(0).default(5),
|
|
108
|
-
AUDIO_CLIP_POST_ROLL: z.coerce.number().min(0).default(15),
|
|
109
|
-
AUDIO_LLM_BOOST_WINDOW: z.coerce.number().min(0).default(10),
|
|
110
|
-
AUDIO_LLM_SCORE_BOOST: z.coerce.number().min(0).default(2),
|
|
111
|
-
GAME_PROFILE: z.enum(['valorant', 'fps', 'boss_fight', 'general']).default('general'),
|
|
112
|
-
YT_DLP_COOKIES_FROM_BROWSER: z
|
|
113
|
-
.enum(['chrome', 'firefox', 'safari', 'brave', 'edge', 'opera', 'chromium'])
|
|
114
|
-
.optional(),
|
|
115
|
-
YT_DLP_COOKIES_FILE: z.string().optional(),
|
|
116
|
-
})
|
|
117
|
-
.superRefine((data, ctx) => {
|
|
118
|
-
const provider = data.LLM_PROVIDER;
|
|
119
|
-
const keyName = PROVIDER_KEY_MAP[provider];
|
|
120
|
-
const keyValue = data[keyName as keyof typeof data] as string | undefined;
|
|
121
|
-
|
|
122
|
-
if (!keyValue || keyValue.trim() === '') {
|
|
123
|
-
ctx.addIssue({
|
|
124
|
-
code: z.ZodIssueCode.custom,
|
|
125
|
-
path: [keyName],
|
|
126
|
-
message: `${keyName} is required when LLM_PROVIDER is "${provider}"`,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
provider === 'custom' &&
|
|
132
|
-
(!data.CUSTOM_OPENAI_BASE_URL || data.CUSTOM_OPENAI_BASE_URL.trim() === '')
|
|
133
|
-
) {
|
|
134
|
-
ctx.addIssue({
|
|
135
|
-
code: z.ZodIssueCode.custom,
|
|
136
|
-
path: ['CUSTOM_OPENAI_BASE_URL'],
|
|
137
|
-
message: 'CUSTOM_OPENAI_BASE_URL is required when LLM_PROVIDER is "custom"',
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (data.YT_DLP_COOKIES_FROM_BROWSER && data.YT_DLP_COOKIES_FILE) {
|
|
142
|
-
ctx.addIssue({
|
|
143
|
-
code: z.ZodIssueCode.custom,
|
|
144
|
-
path: ['YT_DLP_COOKIES_FROM_BROWSER'],
|
|
145
|
-
message:
|
|
146
|
-
'Cannot set both YT_DLP_COOKIES_FROM_BROWSER and YT_DLP_COOKIES_FILE. Use only one.',
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
export type Config = z.infer<typeof ConfigSchema>;
|
package/src/types/downloader.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import type { RankedSegment } from './index.js';
|
|
2
|
-
|
|
3
|
-
export type DownloadMode = 'all' | 'segments';
|
|
4
|
-
|
|
5
|
-
export interface DownloadResultAll {
|
|
6
|
-
mode: 'all';
|
|
7
|
-
path: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface DownloadResultSegments {
|
|
11
|
-
mode: 'segments';
|
|
12
|
-
paths: string[];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export type DownloadResult = DownloadResultAll | DownloadResultSegments;
|
package/src/types/factory.ts
DELETED
package/src/types/index.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
export { ConfigSchema } from './config.js';
|
|
2
|
-
export type { Config } from './config.js';
|
|
3
|
-
|
|
4
|
-
export { TranscriptLineSchema, MicroBlockSchema, LLMChunkSchema } from './transcript.js';
|
|
5
|
-
export type { TranscriptLine, MicroBlock, LLMChunk } from './transcript.js';
|
|
6
|
-
|
|
7
|
-
export { AnalyzedSegmentSchema, RankedSegmentSchema, ChunkEvaluationSchema } from './segment.js';
|
|
8
|
-
export type { AnalyzedSegment, RankedSegment, ChunkEvaluation } from './segment.js';
|
|
9
|
-
|
|
10
|
-
export { AudioEventSchema, MergedCandidateSchema } from './audio.js';
|
|
11
|
-
export type { AudioEvent, MergedCandidate } from './audio.js';
|
|
12
|
-
|
|
13
|
-
export { VideoMetadataSchema, PipelineResultSchema } from './video.js';
|
|
14
|
-
export type { VideoMetadata, PipelineResult } from './video.js';
|
|
15
|
-
|
|
16
|
-
export type { CliArgs } from './cli.js';
|
|
17
|
-
|
|
18
|
-
export type {
|
|
19
|
-
ChunkWindow,
|
|
20
|
-
VideoResolverResult,
|
|
21
|
-
AudioProcessorOpts,
|
|
22
|
-
SegmentAnalyzerOpts,
|
|
23
|
-
SegmentAnalyzerResult,
|
|
24
|
-
SegmentSelectorOpts,
|
|
25
|
-
ClipExporterOpts,
|
|
26
|
-
} from './pipeline.js';
|
|
27
|
-
|
|
28
|
-
export type { LLMAnalyzerResult, LLMAnalyzerOpts, TranscriptDetectorResult } from './analyzer.js';
|
|
29
|
-
|
|
30
|
-
export type {
|
|
31
|
-
DownloadMode,
|
|
32
|
-
DownloadResultAll,
|
|
33
|
-
DownloadResultSegments,
|
|
34
|
-
DownloadResult,
|
|
35
|
-
} from './downloader.js';
|
|
36
|
-
|
|
37
|
-
export { SegmentRefinementSchema } from './cache.js';
|
|
38
|
-
export type { SegmentRefinement } from './cache.js';
|
|
39
|
-
|
|
40
|
-
export type { TranscriptProviderName, AudioProviderName } from './factory.js';
|
package/src/types/pipeline.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
VideoMetadata,
|
|
3
|
-
TranscriptLine,
|
|
4
|
-
MicroBlock,
|
|
5
|
-
LLMChunk,
|
|
6
|
-
ChunkEvaluation,
|
|
7
|
-
} from './index.js';
|
|
8
|
-
|
|
9
|
-
/** A half-open time window [start, end) in seconds. Returned by `buildWindows`. */
|
|
10
|
-
export interface ChunkWindow {
|
|
11
|
-
/** Start of the window in seconds (inclusive). */
|
|
12
|
-
start: number;
|
|
13
|
-
/** End of the window in seconds (exclusive upper bound). */
|
|
14
|
-
end: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface VideoResolverResult {
|
|
18
|
-
videoId: string;
|
|
19
|
-
metadata: VideoMetadata;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface AudioProcessorOpts {
|
|
23
|
-
noAudio: boolean;
|
|
24
|
-
gameProfile: string;
|
|
25
|
-
maxParallel: number;
|
|
26
|
-
/** Pre-downloaded audio WAV path. When provided, skips the downloadAudio call. */
|
|
27
|
-
audioPath?: string | null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface SegmentAnalyzerOpts {
|
|
31
|
-
maxChunks?: number;
|
|
32
|
-
maxParallel: number;
|
|
33
|
-
noCache: boolean;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface SegmentAnalyzerResult {
|
|
37
|
-
lines: TranscriptLine[];
|
|
38
|
-
microBlocks: MicroBlock[];
|
|
39
|
-
chunks: LLMChunk[];
|
|
40
|
-
chunkEvals: ChunkEvaluation[];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface SegmentSelectorOpts {
|
|
44
|
-
threshold: number;
|
|
45
|
-
topN: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface ClipExporterOpts {
|
|
49
|
-
/** Path to a pre-existing local video file. Skips yt-dlp download entirely. */
|
|
50
|
-
localVideo?: string;
|
|
51
|
-
/**
|
|
52
|
-
* yt-dlp download strategy.
|
|
53
|
-
* - `'all'` — download the full video, then cut clips with ffmpeg
|
|
54
|
-
* - number — download only the top-N segments via --download-sections
|
|
55
|
-
* - undefined — same as `'all'`
|
|
56
|
-
*/
|
|
57
|
-
downloadSections: 'all' | number | undefined;
|
|
58
|
-
/** Custom output/download directory (overrides config.DOWNLOAD_DIR / config.OUTPUT_DIR). */
|
|
59
|
-
videoPath?: string;
|
|
60
|
-
}
|
package/src/types/segment.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
|
|
3
|
-
export const AnalyzedSegmentSchema = z.object({
|
|
4
|
-
interesting: z.boolean(),
|
|
5
|
-
score: z.number().min(1).max(10),
|
|
6
|
-
reason: z.string(),
|
|
7
|
-
clip_start: z.number(),
|
|
8
|
-
clip_end: z.number(),
|
|
9
|
-
});
|
|
10
|
-
export type AnalyzedSegment = z.infer<typeof AnalyzedSegmentSchema>;
|
|
11
|
-
|
|
12
|
-
export const RankedSegmentSchema = z.object({
|
|
13
|
-
rank: z.number().int().min(1),
|
|
14
|
-
start: z.number(),
|
|
15
|
-
end: z.number(),
|
|
16
|
-
score: z.number().min(1).max(10),
|
|
17
|
-
reason: z.string(),
|
|
18
|
-
source: z.enum(['transcript', 'audio', 'both']),
|
|
19
|
-
audio_event: z.string().optional(),
|
|
20
|
-
});
|
|
21
|
-
export type RankedSegment = z.infer<typeof RankedSegmentSchema>;
|
|
22
|
-
|
|
23
|
-
const ChunkEvaluationBaseSchema = z.object({
|
|
24
|
-
chunk_index: z.number().int().min(0),
|
|
25
|
-
chunk_start: z.number(),
|
|
26
|
-
chunk_end: z.number(),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
export const ChunkEvaluationSchema = z.discriminatedUnion('status', [
|
|
30
|
-
ChunkEvaluationBaseSchema.extend({
|
|
31
|
-
status: z.literal('success'),
|
|
32
|
-
interesting: z.boolean(),
|
|
33
|
-
score: z.number().min(1).max(10),
|
|
34
|
-
reason: z.string(),
|
|
35
|
-
clip_start: z.number(),
|
|
36
|
-
clip_end: z.number(),
|
|
37
|
-
}),
|
|
38
|
-
ChunkEvaluationBaseSchema.extend({
|
|
39
|
-
status: z.literal('failed'),
|
|
40
|
-
error: z.string(),
|
|
41
|
-
}),
|
|
42
|
-
]);
|
|
43
|
-
export type ChunkEvaluation = z.infer<typeof ChunkEvaluationSchema>;
|
package/src/types/transcript.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
|
|
3
|
-
export const TranscriptLineSchema = z.object({
|
|
4
|
-
text: z.string(),
|
|
5
|
-
start: z.number(),
|
|
6
|
-
duration: z.number(),
|
|
7
|
-
});
|
|
8
|
-
export type TranscriptLine = z.infer<typeof TranscriptLineSchema>;
|
|
9
|
-
|
|
10
|
-
export const MicroBlockSchema = z.object({
|
|
11
|
-
start: z.number(),
|
|
12
|
-
end: z.number(),
|
|
13
|
-
text: z.string(),
|
|
14
|
-
});
|
|
15
|
-
export type MicroBlock = z.infer<typeof MicroBlockSchema>;
|
|
16
|
-
|
|
17
|
-
export const LLMChunkSchema = z.object({
|
|
18
|
-
start: z.number(),
|
|
19
|
-
end: z.number(),
|
|
20
|
-
text: z.string(),
|
|
21
|
-
});
|
|
22
|
-
export type LLMChunk = z.infer<typeof LLMChunkSchema>;
|
package/src/types/video.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import { RankedSegmentSchema, ChunkEvaluationSchema } from './segment.js';
|
|
3
|
-
|
|
4
|
-
export const VideoMetadataSchema = z.object({
|
|
5
|
-
videoId: z.string().length(11),
|
|
6
|
-
title: z.string(),
|
|
7
|
-
duration: z.number(), // seconds
|
|
8
|
-
});
|
|
9
|
-
export type VideoMetadata = z.infer<typeof VideoMetadataSchema>;
|
|
10
|
-
|
|
11
|
-
export const PipelineResultSchema = z.object({
|
|
12
|
-
video_id: z.string().length(11),
|
|
13
|
-
title: z.string(),
|
|
14
|
-
duration: z.number(), // seconds
|
|
15
|
-
chunk_evaluations: z.array(ChunkEvaluationSchema),
|
|
16
|
-
segments: z.array(RankedSegmentSchema),
|
|
17
|
-
});
|
|
18
|
-
export type PipelineResult = z.infer<typeof PipelineResultSchema>;
|
package/src/utils/cache.ts
DELETED
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { promises as fs } from 'fs';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { z } from 'zod';
|
|
5
|
-
import { log } from './logger.js';
|
|
6
|
-
import {
|
|
7
|
-
TranscriptLineSchema,
|
|
8
|
-
ChunkEvaluationSchema,
|
|
9
|
-
AudioEventSchema,
|
|
10
|
-
SegmentRefinementSchema,
|
|
11
|
-
} from '../types/index.js';
|
|
12
|
-
import type {
|
|
13
|
-
TranscriptLine,
|
|
14
|
-
LLMChunk,
|
|
15
|
-
ChunkEvaluation,
|
|
16
|
-
AudioEvent,
|
|
17
|
-
SegmentRefinement,
|
|
18
|
-
} from '../types/index.js';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Serializes audio events into a stable string for cache keying.
|
|
22
|
-
* Events are sorted by time so the key is order-independent.
|
|
23
|
-
*/
|
|
24
|
-
function audioEventsKey(events: AudioEvent[]): string {
|
|
25
|
-
if (events.length === 0) return '';
|
|
26
|
-
const sorted = [...events].sort((a, b) => a.time - b.time);
|
|
27
|
-
return JSON.stringify(sorted);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function hashContent(input: string): string {
|
|
31
|
-
return createHash('sha256').update(input).digest('hex');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function readCacheFile<T>(filePath: string, schema: z.ZodType<T>): Promise<T | null> {
|
|
35
|
-
try {
|
|
36
|
-
const raw = await fs.readFile(filePath, 'utf-8');
|
|
37
|
-
const parsed = schema.safeParse(JSON.parse(raw));
|
|
38
|
-
if (!parsed.success) {
|
|
39
|
-
log.warn(`[cache] Corrupt entry at ${filePath} — ignoring`);
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
return parsed.data;
|
|
43
|
-
} catch {
|
|
44
|
-
// File not found or unreadable — normal cache miss, stay silent
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function writeCacheFile(filePath: string, data: unknown): Promise<void> {
|
|
50
|
-
try {
|
|
51
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
52
|
-
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
53
|
-
} catch (err) {
|
|
54
|
-
log.warn(
|
|
55
|
-
`[cache] Failed to write ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Disk-backed cache for all pipeline stages.
|
|
62
|
-
*
|
|
63
|
-
* Constructed once in runner.ts with the resolved cache directory and passed
|
|
64
|
-
* down to each stage that needs caching. Pass `disabled = true` to bypass all
|
|
65
|
-
* reads and writes (equivalent to --no-cache).
|
|
66
|
-
*/
|
|
67
|
-
export class Cache {
|
|
68
|
-
constructor(
|
|
69
|
-
private readonly cacheDir: string,
|
|
70
|
-
private readonly disabled: boolean = false,
|
|
71
|
-
) {}
|
|
72
|
-
|
|
73
|
-
// ---- Transcript ---------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
private transcriptPath(videoId: string): string {
|
|
76
|
-
return path.join(this.cacheDir, 'transcript', `${hashContent(videoId)}.json`);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async readTranscript(videoId: string): Promise<TranscriptLine[] | null> {
|
|
80
|
-
if (this.disabled) return null;
|
|
81
|
-
return readCacheFile(this.transcriptPath(videoId), z.array(TranscriptLineSchema));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async writeTranscript(videoId: string, lines: TranscriptLine[]): Promise<void> {
|
|
85
|
-
if (this.disabled) return;
|
|
86
|
-
await writeCacheFile(this.transcriptPath(videoId), lines);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ---- LLM chunk results --------------------------------------------------
|
|
90
|
-
|
|
91
|
-
private chunkPath(chunk: LLMChunk, chunkAudioEvents: AudioEvent[] = []): string {
|
|
92
|
-
const audioKey = audioEventsKey(chunkAudioEvents);
|
|
93
|
-
return path.join(
|
|
94
|
-
this.cacheDir,
|
|
95
|
-
'chunks',
|
|
96
|
-
`${hashContent(`${chunk.start}|${chunk.end}|${chunk.text}|${audioKey}`)}.json`,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async readChunk(
|
|
101
|
-
chunk: LLMChunk,
|
|
102
|
-
chunkAudioEvents: AudioEvent[] = [],
|
|
103
|
-
): Promise<ChunkEvaluation | null> {
|
|
104
|
-
if (this.disabled) return null;
|
|
105
|
-
return readCacheFile(this.chunkPath(chunk, chunkAudioEvents), ChunkEvaluationSchema);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async writeChunk(
|
|
109
|
-
chunk: LLMChunk,
|
|
110
|
-
evaluation: ChunkEvaluation,
|
|
111
|
-
chunkAudioEvents: AudioEvent[] = [],
|
|
112
|
-
): Promise<void> {
|
|
113
|
-
if (this.disabled) return;
|
|
114
|
-
if (evaluation.status !== 'success') return;
|
|
115
|
-
await writeCacheFile(this.chunkPath(chunk, chunkAudioEvents), evaluation);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ---- Segment refinement -------------------------------------------------
|
|
119
|
-
|
|
120
|
-
private segmentRefinementPath(start: number, end: number, reason: string): string {
|
|
121
|
-
return path.join(this.cacheDir, 'segments', `${hashContent(`${start}|${end}|${reason}`)}.json`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async readSegmentRefinement(
|
|
125
|
-
start: number,
|
|
126
|
-
end: number,
|
|
127
|
-
reason: string,
|
|
128
|
-
): Promise<SegmentRefinement | null> {
|
|
129
|
-
if (this.disabled) return null;
|
|
130
|
-
return readCacheFile(this.segmentRefinementPath(start, end, reason), SegmentRefinementSchema);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async writeSegmentRefinement(
|
|
134
|
-
start: number,
|
|
135
|
-
end: number,
|
|
136
|
-
reason: string,
|
|
137
|
-
refined: SegmentRefinement,
|
|
138
|
-
): Promise<void> {
|
|
139
|
-
if (this.disabled) return;
|
|
140
|
-
await writeCacheFile(this.segmentRefinementPath(start, end, reason), refined);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ---- Audio events (whole-video) -----------------------------------------
|
|
144
|
-
|
|
145
|
-
private audioEventPath(videoId: string, gameProfile: string, provider: string): string {
|
|
146
|
-
return path.join(
|
|
147
|
-
this.cacheDir,
|
|
148
|
-
'audio',
|
|
149
|
-
`${hashContent(`${videoId}|${gameProfile}|${provider}`)}.json`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async readAudioEvents(
|
|
154
|
-
videoId: string,
|
|
155
|
-
gameProfile: string,
|
|
156
|
-
provider: string,
|
|
157
|
-
): Promise<AudioEvent[] | null> {
|
|
158
|
-
if (this.disabled) return null;
|
|
159
|
-
return readCacheFile(
|
|
160
|
-
this.audioEventPath(videoId, gameProfile, provider),
|
|
161
|
-
z.array(AudioEventSchema),
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async writeAudioEvents(
|
|
166
|
-
videoId: string,
|
|
167
|
-
gameProfile: string,
|
|
168
|
-
provider: string,
|
|
169
|
-
events: AudioEvent[],
|
|
170
|
-
): Promise<void> {
|
|
171
|
-
if (this.disabled) return;
|
|
172
|
-
await writeCacheFile(this.audioEventPath(videoId, gameProfile, provider), events);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ---- Audio events (per-chunk) -------------------------------------------
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Per-chunk audio cache — mirrors the LLM `chunks/` pattern.
|
|
179
|
-
* Key includes videoId, gameProfile, provider, and the exact window bounds
|
|
180
|
-
* so each 120s slice is stored independently.
|
|
181
|
-
*/
|
|
182
|
-
private audioChunkPath(
|
|
183
|
-
videoId: string,
|
|
184
|
-
gameProfile: string,
|
|
185
|
-
provider: string,
|
|
186
|
-
windowStart: number,
|
|
187
|
-
windowEnd: number,
|
|
188
|
-
): string {
|
|
189
|
-
return path.join(
|
|
190
|
-
this.cacheDir,
|
|
191
|
-
'audio',
|
|
192
|
-
`${hashContent(`${videoId}|${gameProfile}|${provider}|${windowStart}|${windowEnd}`)}.json`,
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async readAudioChunk(
|
|
197
|
-
videoId: string,
|
|
198
|
-
gameProfile: string,
|
|
199
|
-
provider: string,
|
|
200
|
-
windowStart: number,
|
|
201
|
-
windowEnd: number,
|
|
202
|
-
): Promise<AudioEvent[] | null> {
|
|
203
|
-
if (this.disabled) return null;
|
|
204
|
-
return readCacheFile(
|
|
205
|
-
this.audioChunkPath(videoId, gameProfile, provider, windowStart, windowEnd),
|
|
206
|
-
z.array(AudioEventSchema),
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async writeAudioChunk(
|
|
211
|
-
videoId: string,
|
|
212
|
-
gameProfile: string,
|
|
213
|
-
provider: string,
|
|
214
|
-
windowStart: number,
|
|
215
|
-
windowEnd: number,
|
|
216
|
-
events: AudioEvent[],
|
|
217
|
-
): Promise<void> {
|
|
218
|
-
if (this.disabled) return;
|
|
219
|
-
await writeCacheFile(
|
|
220
|
-
this.audioChunkPath(videoId, gameProfile, provider, windowStart, windowEnd),
|
|
221
|
-
events,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
}
|
package/src/utils/chunker.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Generic windowed-chunker utility.
|
|
3
|
-
*
|
|
4
|
-
* Used by:
|
|
5
|
-
* - transcriptProcessor — builds LLM analysis windows over micro-blocks
|
|
6
|
-
* - audioProcessor — builds audio slice windows over the video duration
|
|
7
|
-
*
|
|
8
|
-
* The function returns non-overlapping or overlapping half-open intervals
|
|
9
|
-
* [start, end) that together cover the full range [0, totalDuration).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { ChunkWindow } from '../types/index.js';
|
|
13
|
-
|
|
14
|
-
export type { ChunkWindow };
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Builds a list of time windows covering `[0, totalDuration)`.
|
|
18
|
-
*
|
|
19
|
-
* @param totalDuration - Total duration of the content in seconds.
|
|
20
|
-
* @param windowSec - Width of each window in seconds. Must be > 0.
|
|
21
|
-
* @param overlapSec - How many seconds consecutive windows share. Must be
|
|
22
|
-
* >= 0 and < windowSec. Defaults to 0.
|
|
23
|
-
* @returns Array of {start, end} windows. Empty when totalDuration <= 0.
|
|
24
|
-
*
|
|
25
|
-
* @example
|
|
26
|
-
* // No overlap — three equal windows
|
|
27
|
-
* buildWindows(60, 20)
|
|
28
|
-
* // → [{start:0,end:20}, {start:20,end:40}, {start:40,end:60}]
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* // With overlap — each window starts 10s after the previous
|
|
32
|
-
* buildWindows(60, 30, 10)
|
|
33
|
-
* // → [{start:0,end:30}, {start:20,end:50}, {start:40,end:60}]
|
|
34
|
-
*
|
|
35
|
-
* @example
|
|
36
|
-
* // Remainder — last window is shorter
|
|
37
|
-
* buildWindows(70, 30)
|
|
38
|
-
* // → [{start:0,end:30}, {start:30,end:60}, {start:60,end:70}]
|
|
39
|
-
*/
|
|
40
|
-
export function buildWindows(
|
|
41
|
-
totalDuration: number,
|
|
42
|
-
windowSec: number,
|
|
43
|
-
overlapSec: number = 0,
|
|
44
|
-
): ChunkWindow[] {
|
|
45
|
-
if (totalDuration <= 0 || windowSec <= 0) return [];
|
|
46
|
-
if (overlapSec < 0) overlapSec = 0;
|
|
47
|
-
if (overlapSec >= windowSec) overlapSec = 0; // guard against infinite loop
|
|
48
|
-
|
|
49
|
-
const step = windowSec - overlapSec;
|
|
50
|
-
const windows: ChunkWindow[] = [];
|
|
51
|
-
|
|
52
|
-
for (let start = 0; start < totalDuration; start += step) {
|
|
53
|
-
windows.push({
|
|
54
|
-
start,
|
|
55
|
-
end: Math.min(start + windowSec, totalDuration),
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return windows;
|
|
60
|
-
}
|
package/src/utils/dumper.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { config } from '../config/index.js';
|
|
4
|
-
import { log } from './logger.js';
|
|
5
|
-
import type { TranscriptLine, PipelineResult } from '../types/index.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Writes the raw normalized transcript lines to
|
|
9
|
-
* `{OUTPUT_DIR}/transcript/{videoId}.json`.
|
|
10
|
-
*/
|
|
11
|
-
export async function dumpTranscript(videoId: string, lines: TranscriptLine[]): Promise<void> {
|
|
12
|
-
try {
|
|
13
|
-
const dir = path.join(config.OUTPUT_DIR, 'transcript');
|
|
14
|
-
await fs.mkdir(dir, { recursive: true });
|
|
15
|
-
const filePath = path.join(dir, `${videoId}.json`);
|
|
16
|
-
await fs.writeFile(filePath, JSON.stringify(lines, null, 2), 'utf-8');
|
|
17
|
-
log.info(`Transcript dumped to ${filePath}`);
|
|
18
|
-
} catch (err) {
|
|
19
|
-
log.warn(
|
|
20
|
-
`Failed to dump transcript for ${videoId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Writes the full pipeline result (metadata + ranked segments) to
|
|
27
|
-
* `{OUTPUT_DIR}/analysis/{videoId}.json`.
|
|
28
|
-
*/
|
|
29
|
-
export async function dumpAnalysis(videoId: string, result: PipelineResult): Promise<void> {
|
|
30
|
-
try {
|
|
31
|
-
const dir = path.join(config.OUTPUT_DIR, 'analysis');
|
|
32
|
-
await fs.mkdir(dir, { recursive: true });
|
|
33
|
-
const filePath = path.join(dir, `${videoId}.json`);
|
|
34
|
-
await fs.writeFile(filePath, JSON.stringify(result, null, 2), 'utf-8');
|
|
35
|
-
log.info(`Analysis dumped to ${filePath}`);
|
|
36
|
-
} catch (err) {
|
|
37
|
-
log.warn(
|
|
38
|
-
`Failed to dump analysis for ${videoId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
}
|
package/src/utils/format.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Converts a duration in seconds to HH:MM:SS string.
|
|
3
|
-
* e.g. 3723 → "01:02:03"
|
|
4
|
-
*/
|
|
5
|
-
export function formatSeconds(seconds: number): string {
|
|
6
|
-
const h = Math.floor(seconds / 3600);
|
|
7
|
-
const m = Math.floor((seconds % 3600) / 60);
|
|
8
|
-
const s = Math.floor(seconds % 60);
|
|
9
|
-
return [h, m, s].map((v) => String(v).padStart(2, '0')).join(':');
|
|
10
|
-
}
|