@mevdragon/vidfarm-devcli 0.7.1 → 0.8.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/demo/dist/app.js +49 -49
- package/dist/src/app.js +863 -2
- package/dist/src/cli.js +59 -3
- package/dist/src/config.js +6 -0
- package/dist/src/devcli/clip-store.js +335 -0
- package/dist/src/devcli/clips.js +803 -0
- package/dist/src/frontend/homepage-view.js +5 -0
- package/dist/src/services/clip-curation/cost.js +112 -0
- package/dist/src/services/clip-curation/ffmpeg.js +258 -0
- package/dist/src/services/clip-curation/gemini.js +342 -0
- package/dist/src/services/clip-curation/index.js +15 -0
- package/dist/src/services/clip-curation/presets.js +20 -0
- package/dist/src/services/clip-curation/presets.v1.json +59 -0
- package/dist/src/services/clip-curation/query.js +163 -0
- package/dist/src/services/clip-curation/refine.js +136 -0
- package/dist/src/services/clip-curation/scan.js +137 -0
- package/dist/src/services/clip-curation/taxonomy.js +71 -0
- package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
- package/dist/src/services/clip-curation/types.js +7 -0
- package/dist/src/services/clip-records.js +209 -0
- package/dist/src/services/clip-search.js +47 -0
- package/dist/src/services/clip-vectors.js +125 -0
- package/dist/src/services/hyperframes.js +347 -0
- package/dist/src/services/scene-annotations.js +32 -0
- package/package.json +5 -1
- package/public/assets/homepage-client-app.js +17 -17
|
@@ -11,6 +11,9 @@ function LibraryIcon() {
|
|
|
11
11
|
function CalendarIcon() {
|
|
12
12
|
return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("circle", { cx: "10", cy: "10", r: "7.2" }), _jsx("path", { d: "M10 6.3v4.1l2.8 1.7" })] });
|
|
13
13
|
}
|
|
14
|
+
function ClipsIcon() {
|
|
15
|
+
return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { x: "3", y: "4.5", width: "14", height: "11", rx: "1.5" }), _jsx("path", { d: "M3 8h14" }), _jsx("path", { d: "M6.2 4.7 4.6 8" }), _jsx("path", { d: "M9.6 4.7 8 8" }), _jsx("path", { d: "M13 4.7 11.4 8" })] });
|
|
16
|
+
}
|
|
14
17
|
function ChatIcon() {
|
|
15
18
|
return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M4.2 5.2a2 2 0 0 1 2-2h7.6a2 2 0 0 1 2 2v5.6a2 2 0 0 1-2 2H9.2l-4.1 3.1v-3.1a2 2 0 0 1-.9-1.7Z" }), _jsx("path", { d: "M7.3 7.1h5.4" }), _jsx("path", { d: "M7.3 9.6h3.7" })] });
|
|
16
19
|
}
|
|
@@ -55,11 +58,13 @@ function PrimaryNav({ account }) {
|
|
|
55
58
|
? [
|
|
56
59
|
{ id: "discover", href: withAccountQuery("/discover", account.userId), label: "Discover", icon: _jsx(DiscoverIcon, {}) },
|
|
57
60
|
{ id: "library", href: withAccountQuery("/library", account.userId), label: "Library", icon: _jsx(LibraryIcon, {}) },
|
|
61
|
+
{ id: "clips", href: "/clips", label: "Clips", icon: _jsx(ClipsIcon, {}) },
|
|
58
62
|
{ id: "calendar", href: withAccountQuery("/calendar", account.userId), label: "Calendar", icon: _jsx(CalendarIcon, {}) },
|
|
59
63
|
{ id: "settings", href: withAccountQuery("/settings", account.userId), label: "Settings", icon: _jsx(SettingsIcon, {}) }
|
|
60
64
|
]
|
|
61
65
|
: [
|
|
62
66
|
{ id: "discover", href: "/discover", label: "Discover", icon: _jsx(DiscoverIcon, {}) },
|
|
67
|
+
{ id: "clips", href: "/clips", label: "Clips", icon: _jsx(ClipsIcon, {}) },
|
|
63
68
|
{ id: "login", href: "/login", label: "Login", icon: _jsx(SettingsIcon, {}) }
|
|
64
69
|
];
|
|
65
70
|
const menuItems = account.isLoggedIn
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Cost estimation for a scan — surfaced BEFORE every scan (success criterion #6)
|
|
2
|
+
// and used by the devcli --dry-run gate. Provider/model-aware: prices the
|
|
3
|
+
// resolved tagging model (gemini / openai gpt-5.4-nano / openrouter gemini) plus
|
|
4
|
+
// the embedding model. BYOK: this is the LLM spend the user pays directly.
|
|
5
|
+
function envPrice(envKey, fallback) {
|
|
6
|
+
const v = Number(process.env[envKey]);
|
|
7
|
+
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
8
|
+
}
|
|
9
|
+
// Approximate list prices, USD per 1M tokens (overridable via env). Estimates —
|
|
10
|
+
// the real bill comes from the user's provider account.
|
|
11
|
+
const MODEL_PRICING = {
|
|
12
|
+
"gemini-2.5-flash-lite": { input: envPrice("VIDFARM_PRICE_FLASH_LITE_INPUT", 0.1), output: envPrice("VIDFARM_PRICE_FLASH_LITE_OUTPUT", 0.4) },
|
|
13
|
+
"gemini-2.5-flash": { input: envPrice("VIDFARM_PRICE_FLASH_INPUT", 0.3), output: envPrice("VIDFARM_PRICE_FLASH_OUTPUT", 2.5) },
|
|
14
|
+
// OpenRouter adds a small markup over Google's list price.
|
|
15
|
+
"google/gemini-2.5-flash": { input: envPrice("VIDFARM_PRICE_OR_FLASH_INPUT", 0.315), output: envPrice("VIDFARM_PRICE_OR_FLASH_OUTPUT", 2.625) },
|
|
16
|
+
"gpt-5.4-nano": { input: envPrice("VIDFARM_PRICE_GPT5_NANO_INPUT", 0.05), output: envPrice("VIDFARM_PRICE_GPT5_NANO_OUTPUT", 0.4) }
|
|
17
|
+
};
|
|
18
|
+
const DEFAULT_TAG_PRICING = { input: 0.15, output: 0.6 };
|
|
19
|
+
const EMBED_PRICING = {
|
|
20
|
+
"gemini-embedding-001": envPrice("VIDFARM_PRICE_EMBEDDING_INPUT", 0.15),
|
|
21
|
+
"text-embedding-3-small": envPrice("VIDFARM_PRICE_OPENAI_EMBEDDING_INPUT", 0.02)
|
|
22
|
+
};
|
|
23
|
+
// Low-res image tokens: Gemini media_resolution LOW = 66/frame; OpenAI
|
|
24
|
+
// low-detail ≈ 85/frame. Audio ~32 tok/sec (Gemini only — OpenAI/OpenRouter
|
|
25
|
+
// chat models don't take the raw audio clip, so those skip it).
|
|
26
|
+
function imageTokensPerFrame(provider) {
|
|
27
|
+
return provider === "openai" ? 85 : 66;
|
|
28
|
+
}
|
|
29
|
+
const PROMPT_OVERHEAD_TOKENS = 280;
|
|
30
|
+
const AUDIO_TOKENS_PER_SEC = 32;
|
|
31
|
+
// OpenAI's nano reasoning model emits more output tokens than Gemini's terse JSON.
|
|
32
|
+
function outputTokensPerScene(provider) {
|
|
33
|
+
return provider === "openai" ? 350 : 150;
|
|
34
|
+
}
|
|
35
|
+
const EMBEDDING_TOKENS_PER_SCENE = 60;
|
|
36
|
+
const DEFAULT_AVG_SCENE_SEC = 4;
|
|
37
|
+
export function estimateScanCostFromScenes(scenes, opts) {
|
|
38
|
+
const durationSec = scenes.reduce((acc, s) => acc + s.duration_sec, 0);
|
|
39
|
+
const avgSceneSec = scenes.length ? durationSec / scenes.length : DEFAULT_AVG_SCENE_SEC;
|
|
40
|
+
return compute({ ...normalize(opts), durationSec, scenes: scenes.length, avgSceneSec });
|
|
41
|
+
}
|
|
42
|
+
export function estimateScanCostFromDuration(durationSec, opts) {
|
|
43
|
+
const avgSceneSec = opts.avgSceneSec ?? DEFAULT_AVG_SCENE_SEC;
|
|
44
|
+
const scenes = Math.max(1, Math.ceil(durationSec / avgSceneSec));
|
|
45
|
+
return compute({ ...normalize(opts), durationSec, scenes, avgSceneSec });
|
|
46
|
+
}
|
|
47
|
+
function normalize(opts) {
|
|
48
|
+
const provider = opts.provider ?? "gemini";
|
|
49
|
+
const tagModelId = opts.tagModelId ?? defaultTagModel(provider, opts.tier);
|
|
50
|
+
return {
|
|
51
|
+
tier: opts.tier,
|
|
52
|
+
provider,
|
|
53
|
+
tagModelId,
|
|
54
|
+
embedModelId: opts.embedModelId ?? null,
|
|
55
|
+
framesPerScene: opts.framesPerScene ?? 2,
|
|
56
|
+
includeAudio: opts.includeAudio ?? true
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function defaultTagModel(provider, tier) {
|
|
60
|
+
if (provider === "openai")
|
|
61
|
+
return "gpt-5.4-nano";
|
|
62
|
+
if (provider === "openrouter")
|
|
63
|
+
return "google/gemini-2.5-flash";
|
|
64
|
+
return tier === "flash" ? "gemini-2.5-flash" : "gemini-2.5-flash-lite";
|
|
65
|
+
}
|
|
66
|
+
function compute(input) {
|
|
67
|
+
const { provider, tagModelId, embedModelId, framesPerScene, includeAudio, durationSec, scenes, avgSceneSec } = input;
|
|
68
|
+
// Audio is only sent to Gemini (native audio input).
|
|
69
|
+
const audioTokensPerScene = includeAudio && provider === "gemini" ? Math.round(avgSceneSec * AUDIO_TOKENS_PER_SEC) : 0;
|
|
70
|
+
const taggingInputPerScene = framesPerScene * imageTokensPerFrame(provider) + PROMPT_OVERHEAD_TOKENS + audioTokensPerScene;
|
|
71
|
+
const taggingInputTokens = taggingInputPerScene * scenes;
|
|
72
|
+
const taggingOutputTokens = outputTokensPerScene(provider) * scenes;
|
|
73
|
+
const embeddingTokens = embedModelId ? EMBEDDING_TOKENS_PER_SCENE * scenes : 0;
|
|
74
|
+
const tagPrice = MODEL_PRICING[tagModelId] ?? DEFAULT_TAG_PRICING;
|
|
75
|
+
const embedPrice = embedModelId ? EMBED_PRICING[embedModelId] ?? 0.15 : 0;
|
|
76
|
+
const usd = (taggingInputTokens / 1_000_000) * tagPrice.input +
|
|
77
|
+
(taggingOutputTokens / 1_000_000) * tagPrice.output +
|
|
78
|
+
(embeddingTokens / 1_000_000) * embedPrice;
|
|
79
|
+
const hours = durationSec / 3600;
|
|
80
|
+
const usdPerHour = hours > 0 ? usd / hours : usd;
|
|
81
|
+
return {
|
|
82
|
+
tier: input.tier,
|
|
83
|
+
provider,
|
|
84
|
+
tag_model: tagModelId,
|
|
85
|
+
embed_model: embedModelId,
|
|
86
|
+
duration_sec: round2(durationSec),
|
|
87
|
+
estimated_scenes: scenes,
|
|
88
|
+
frames_per_scene: framesPerScene,
|
|
89
|
+
tagging_input_tokens: taggingInputTokens,
|
|
90
|
+
tagging_output_tokens: taggingOutputTokens,
|
|
91
|
+
embedding_tokens: embeddingTokens,
|
|
92
|
+
usd: round4(usd),
|
|
93
|
+
usd_per_source_hour: round4(usdPerHour)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Human-readable one-liner for the CLI / API. */
|
|
97
|
+
export function formatCostEstimate(est) {
|
|
98
|
+
const mins = (est.duration_sec / 60).toFixed(1);
|
|
99
|
+
return [
|
|
100
|
+
`~$${est.usd.toFixed(3)} for ${mins} min`,
|
|
101
|
+
`(${est.estimated_scenes} scenes · ${est.tag_model}${est.embed_model ? ` + ${est.embed_model}` : " · no embeddings"})`,
|
|
102
|
+
`≈ $${est.usd_per_source_hour.toFixed(3)}/source-hour`,
|
|
103
|
+
`| tokens: ${est.tagging_input_tokens.toLocaleString()} in + ${est.tagging_output_tokens.toLocaleString()} out + ${est.embedding_tokens.toLocaleString()} embed`
|
|
104
|
+
].join(" ");
|
|
105
|
+
}
|
|
106
|
+
function round2(n) {
|
|
107
|
+
return Math.round(n * 100) / 100;
|
|
108
|
+
}
|
|
109
|
+
function round4(n) {
|
|
110
|
+
return Math.round(n * 10000) / 10000;
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=cost.js.map
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// ffmpeg/ffprobe helpers for clip curation — scene detection, keyframe/clip/
|
|
2
|
+
// thumbnail extraction, per-scene audio for ASR. Same flags on devcli (local
|
|
3
|
+
// ffmpeg-static) and cloud (bundled ffmpeg-static in the scan Lambda), so both
|
|
4
|
+
// targets cut identical clips. Resolution order matches the rest of the repo:
|
|
5
|
+
// HYPERFRAMES_FFMPEG_PATH/FFPROBE env → ffmpeg-static/ffprobe-static → PATH.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
let cachedFfmpeg;
|
|
10
|
+
let cachedFfprobe;
|
|
11
|
+
export async function resolveFfmpeg() {
|
|
12
|
+
if (cachedFfmpeg !== undefined)
|
|
13
|
+
return cachedFfmpeg ?? "ffmpeg";
|
|
14
|
+
const envPath = process.env.HYPERFRAMES_FFMPEG_PATH?.trim();
|
|
15
|
+
if (envPath && existsSync(envPath))
|
|
16
|
+
return (cachedFfmpeg = envPath);
|
|
17
|
+
try {
|
|
18
|
+
const mod = (await import("ffmpeg-static"));
|
|
19
|
+
const resolved = typeof mod === "string" ? mod : mod.default;
|
|
20
|
+
if (typeof resolved === "string" && resolved && existsSync(resolved))
|
|
21
|
+
return (cachedFfmpeg = resolved);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* fall through to PATH */
|
|
25
|
+
}
|
|
26
|
+
cachedFfmpeg = null;
|
|
27
|
+
return "ffmpeg";
|
|
28
|
+
}
|
|
29
|
+
export async function resolveFfprobe() {
|
|
30
|
+
if (cachedFfprobe !== undefined)
|
|
31
|
+
return cachedFfprobe ?? "ffprobe";
|
|
32
|
+
const envPath = process.env.HYPERFRAMES_FFPROBE_PATH?.trim() ?? process.env.FFPROBE_PATH?.trim();
|
|
33
|
+
if (envPath && existsSync(envPath))
|
|
34
|
+
return (cachedFfprobe = envPath);
|
|
35
|
+
try {
|
|
36
|
+
const mod = (await import("ffprobe-static"));
|
|
37
|
+
const p = (mod.path ?? mod.default?.path);
|
|
38
|
+
if (typeof p === "string" && p && existsSync(p))
|
|
39
|
+
return (cachedFfprobe = p);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* fall through to PATH */
|
|
43
|
+
}
|
|
44
|
+
cachedFfprobe = null;
|
|
45
|
+
return "ffprobe";
|
|
46
|
+
}
|
|
47
|
+
/** True if a usable ffmpeg is available (bundled or on PATH). */
|
|
48
|
+
export async function hasFfmpeg() {
|
|
49
|
+
const bin = await resolveFfmpeg();
|
|
50
|
+
const { code } = await run(bin, ["-version"]).catch(() => ({ code: 1, stdout: "", stderr: "" }));
|
|
51
|
+
return code === 0;
|
|
52
|
+
}
|
|
53
|
+
function run(bin, args) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
56
|
+
let stdout = "";
|
|
57
|
+
let stderr = "";
|
|
58
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
59
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
60
|
+
child.on("error", reject);
|
|
61
|
+
child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr }));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Probe a source video for duration/dimensions/codec/size/fps. */
|
|
65
|
+
export async function probeVideo(videoPath) {
|
|
66
|
+
const ffprobe = await resolveFfprobe();
|
|
67
|
+
const { stdout } = await run(ffprobe, [
|
|
68
|
+
"-v", "error",
|
|
69
|
+
"-print_format", "json",
|
|
70
|
+
"-show_format",
|
|
71
|
+
"-show_streams",
|
|
72
|
+
videoPath
|
|
73
|
+
]);
|
|
74
|
+
let parsed = {};
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(stdout || "{}");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
parsed = {};
|
|
80
|
+
}
|
|
81
|
+
const streams = Array.isArray(parsed.streams) ? parsed.streams : [];
|
|
82
|
+
const video = streams.find((s) => s.codec_type === "video");
|
|
83
|
+
const format = parsed.format ?? {};
|
|
84
|
+
const duration = Number(format.duration ?? video?.duration ?? 0) || 0;
|
|
85
|
+
let fps = null;
|
|
86
|
+
if (video?.avg_frame_rate && typeof video.avg_frame_rate === "string") {
|
|
87
|
+
const [num, den] = video.avg_frame_rate.split("/").map(Number);
|
|
88
|
+
if (num && den)
|
|
89
|
+
fps = num / den;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
duration_sec: duration,
|
|
93
|
+
width: video?.width ?? null,
|
|
94
|
+
height: video?.height ?? null,
|
|
95
|
+
codec: video?.codec_name ?? null,
|
|
96
|
+
size_bytes: format.size ? Number(format.size) : null,
|
|
97
|
+
fps
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Detect scene boundaries with the portable `select='gt(scene,T)',showinfo`
|
|
102
|
+
* pass, parsing pts_time from ffmpeg's stderr. Falls back to fixed-interval
|
|
103
|
+
* chunking when nothing is detected (e.g. a single continuous take), so a scan
|
|
104
|
+
* always yields clips. Same command shape on devcli and cloud.
|
|
105
|
+
*/
|
|
106
|
+
export async function detectScenes(videoPath, opts = {}) {
|
|
107
|
+
const threshold = opts.threshold ?? 0.3;
|
|
108
|
+
const minScene = opts.minSceneSec ?? 0.6;
|
|
109
|
+
const maxScene = opts.maxSceneSec ?? 8;
|
|
110
|
+
const duration = opts.durationSec ?? (await probeVideo(videoPath)).duration_sec;
|
|
111
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
112
|
+
return [];
|
|
113
|
+
const ffmpeg = await resolveFfmpeg();
|
|
114
|
+
const { stderr } = await run(ffmpeg, [
|
|
115
|
+
"-hide_banner",
|
|
116
|
+
"-i", videoPath,
|
|
117
|
+
"-filter:v", `select='gt(scene,${threshold})',showinfo`,
|
|
118
|
+
"-f", "null",
|
|
119
|
+
"-"
|
|
120
|
+
]);
|
|
121
|
+
const cuts = [];
|
|
122
|
+
const re = /pts_time:([0-9.]+)/g;
|
|
123
|
+
let m;
|
|
124
|
+
while ((m = re.exec(stderr)) !== null) {
|
|
125
|
+
const t = Number(m[1]);
|
|
126
|
+
if (Number.isFinite(t) && t > 0 && t < duration)
|
|
127
|
+
cuts.push(t);
|
|
128
|
+
}
|
|
129
|
+
cuts.sort((a, b) => a - b);
|
|
130
|
+
// Build [0, cut1, cut2, …, duration] boundaries.
|
|
131
|
+
let bounds = [0, ...cuts, duration];
|
|
132
|
+
// If nothing detected, chunk at maxScene.
|
|
133
|
+
if (cuts.length === 0) {
|
|
134
|
+
bounds = [0];
|
|
135
|
+
for (let t = maxScene; t < duration; t += maxScene)
|
|
136
|
+
bounds.push(t);
|
|
137
|
+
bounds.push(duration);
|
|
138
|
+
}
|
|
139
|
+
// Merge boundaries closer than minScene.
|
|
140
|
+
const merged = [bounds[0]];
|
|
141
|
+
for (let i = 1; i < bounds.length; i++) {
|
|
142
|
+
if (bounds[i] - merged[merged.length - 1] >= minScene || i === bounds.length - 1) {
|
|
143
|
+
merged.push(bounds[i]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const scenes = [];
|
|
147
|
+
for (let i = 0; i < merged.length - 1; i++) {
|
|
148
|
+
let start = merged[i];
|
|
149
|
+
const end = merged[i + 1];
|
|
150
|
+
// Split over-long scenes so clips stay usable and tagging stays cheap.
|
|
151
|
+
const span = end - start;
|
|
152
|
+
if (span > maxScene) {
|
|
153
|
+
const chunks = Math.ceil(span / maxScene);
|
|
154
|
+
const step = span / chunks;
|
|
155
|
+
for (let c = 0; c < chunks; c++) {
|
|
156
|
+
const s = start + c * step;
|
|
157
|
+
const e = c === chunks - 1 ? end : s + step;
|
|
158
|
+
scenes.push(makeScene(scenes.length, s, e));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
scenes.push(makeScene(scenes.length, start, end));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return scenes;
|
|
166
|
+
}
|
|
167
|
+
function makeScene(index, start, end) {
|
|
168
|
+
const s = Math.max(0, start);
|
|
169
|
+
const e = Math.max(s + 0.01, end);
|
|
170
|
+
return { index, start_time_sec: round3(s), end_time_sec: round3(e), duration_sec: round3(e - s) };
|
|
171
|
+
}
|
|
172
|
+
/** Extract up to `count` small keyframe JPEGs across a scene (for low-cost tagging). */
|
|
173
|
+
export async function extractKeyframes(input) {
|
|
174
|
+
const count = Math.max(1, Math.min(3, input.count ?? 2));
|
|
175
|
+
const width = input.width ?? 384;
|
|
176
|
+
mkdirSync(input.outDir, { recursive: true });
|
|
177
|
+
const ffmpeg = await resolveFfmpeg();
|
|
178
|
+
const { start_time_sec: start, duration_sec: dur } = input.scene;
|
|
179
|
+
// Sample points inside the clip, avoiding the very edges.
|
|
180
|
+
const points = count === 1
|
|
181
|
+
? [start + dur / 2]
|
|
182
|
+
: Array.from({ length: count }, (_, i) => start + dur * ((i + 1) / (count + 1)));
|
|
183
|
+
const outPaths = [];
|
|
184
|
+
for (let i = 0; i < points.length; i++) {
|
|
185
|
+
const out = path.join(input.outDir, `scene-${input.scene.index}-kf${i}.jpg`);
|
|
186
|
+
const { code } = await run(ffmpeg, [
|
|
187
|
+
"-y",
|
|
188
|
+
"-ss", points[i].toFixed(3),
|
|
189
|
+
"-i", input.videoPath,
|
|
190
|
+
"-frames:v", "1",
|
|
191
|
+
"-vf", `scale=${width}:-2`,
|
|
192
|
+
"-q:v", "5",
|
|
193
|
+
out
|
|
194
|
+
]);
|
|
195
|
+
if (code === 0 && existsSync(out))
|
|
196
|
+
outPaths.push(out);
|
|
197
|
+
}
|
|
198
|
+
return outPaths;
|
|
199
|
+
}
|
|
200
|
+
/** Cut the scene into a standalone mp4 (re-encoded for frame-accurate boundaries). */
|
|
201
|
+
export async function extractClip(input) {
|
|
202
|
+
mkdirSync(path.dirname(input.outPath), { recursive: true });
|
|
203
|
+
const ffmpeg = await resolveFfmpeg();
|
|
204
|
+
const { start_time_sec: start, duration_sec: dur } = input.scene;
|
|
205
|
+
const args = input.reencode === false
|
|
206
|
+
? ["-y", "-ss", start.toFixed(3), "-i", input.videoPath, "-t", dur.toFixed(3), "-c", "copy", "-avoid_negative_ts", "make_zero", input.outPath]
|
|
207
|
+
: [
|
|
208
|
+
"-y",
|
|
209
|
+
"-ss", start.toFixed(3),
|
|
210
|
+
"-i", input.videoPath,
|
|
211
|
+
"-t", dur.toFixed(3),
|
|
212
|
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
|
|
213
|
+
"-c:a", "aac", "-b:a", "128k",
|
|
214
|
+
"-movflags", "+faststart",
|
|
215
|
+
input.outPath
|
|
216
|
+
];
|
|
217
|
+
const { code } = await run(ffmpeg, args);
|
|
218
|
+
return code === 0 && existsSync(input.outPath);
|
|
219
|
+
}
|
|
220
|
+
/** Single representative thumbnail for the scene. */
|
|
221
|
+
export async function extractThumbnail(input) {
|
|
222
|
+
mkdirSync(path.dirname(input.outPath), { recursive: true });
|
|
223
|
+
const ffmpeg = await resolveFfmpeg();
|
|
224
|
+
const mid = input.scene.start_time_sec + input.scene.duration_sec / 2;
|
|
225
|
+
const { code } = await run(ffmpeg, [
|
|
226
|
+
"-y",
|
|
227
|
+
"-ss", mid.toFixed(3),
|
|
228
|
+
"-i", input.videoPath,
|
|
229
|
+
"-frames:v", "1",
|
|
230
|
+
"-vf", `scale=${input.width ?? 320}:-2`,
|
|
231
|
+
"-q:v", "3",
|
|
232
|
+
input.outPath
|
|
233
|
+
]);
|
|
234
|
+
return code === 0 && existsSync(input.outPath);
|
|
235
|
+
}
|
|
236
|
+
/** Extract a small mono/16k audio clip for the scene (feeds Gemini ASR). Returns null if no audio track. */
|
|
237
|
+
export async function extractSceneAudio(input) {
|
|
238
|
+
mkdirSync(path.dirname(input.outPath), { recursive: true });
|
|
239
|
+
const ffmpeg = await resolveFfmpeg();
|
|
240
|
+
const { start_time_sec: start, duration_sec: dur } = input.scene;
|
|
241
|
+
const { code } = await run(ffmpeg, [
|
|
242
|
+
"-y",
|
|
243
|
+
"-ss", start.toFixed(3),
|
|
244
|
+
"-i", input.videoPath,
|
|
245
|
+
"-t", dur.toFixed(3),
|
|
246
|
+
"-vn",
|
|
247
|
+
"-ac", "1",
|
|
248
|
+
"-ar", "16000",
|
|
249
|
+
"-c:a", "libmp3lame",
|
|
250
|
+
"-b:a", "32k",
|
|
251
|
+
input.outPath
|
|
252
|
+
]);
|
|
253
|
+
return code === 0 && existsSync(input.outPath) ? input.outPath : null;
|
|
254
|
+
}
|
|
255
|
+
function round3(n) {
|
|
256
|
+
return Math.round(n * 1000) / 1000;
|
|
257
|
+
}
|
|
258
|
+
//# sourceMappingURL=ffmpeg.js.map
|