perso-dubbing 0.2.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.
@@ -0,0 +1,228 @@
1
+ // Scheduler (C/Q concurrency). Pools (chunk × language) from multiple inputs into one queue for concurrent processing.
2
+ // Result: `${inputId}|${index}|${target}` → { inputId, index, target, status, path?, projectId?, name?, reason? }
3
+ // - OK : path of the dubbing result downloaded locally
4
+ // - PASSTHROUGH : silent (split chunk) → original chunk path as-is (merge included)
5
+ // - DLFAIL : generated (has projectSeq) · only download failed → re-download on resume (no regeneration)
6
+ // - HARD_FAIL : excluded from merge (group boundary)
7
+ import { basename, join } from 'node:path';
8
+ import { makeTempDir } from './tmp.mjs';
9
+ import { upload, requestTranslation, getStatus, download, getQueueStatus, cancel } from './api_adapter.mjs';
10
+ import { PersoApiError } from './http_client.mjs';
11
+ import {
12
+ BACKOFF_BASE_MS, BACKOFF_MAX_MS, POLL_INTERVAL_MS,
13
+ MAX_IDLE_MS, MAX_RETRY, QUEUE_WAIT_MS,
14
+ } from './config.mjs';
15
+
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+ // Credit/usage shortage is judged uniformly by HTTP 402 (does not depend on internal code names)
18
+ const isCreditError = (e) => e instanceof PersoApiError && e.httpStatus === 402;
19
+ // Translation queue full (backpressure): observed as 429 VT4292 (FULL_VT_TRANSLATE_QUEUE). VT5034 (503) also preserved.
20
+ const QUEUE_FULL_CODES = new Set(['VT4292', 'VT5034']);
21
+
22
+ // chunks: pieces from multiple inputs in one array (each piece identifies its input by inputId, index is 0-based within the input).
23
+ // An unsplit input has a single piece. The same (inputId,index) shares mediaSeq across languages (uploaded once).
24
+ export async function runSchedule(chunks, spaceSeq, opts = {}, hooks = {}) {
25
+ const log = hooks.log ?? (() => {});
26
+ const onResult = hooks.onResult ?? (() => {}); // fired per confirmed result — lets the caller persist resume state incrementally
27
+ const outDir = await makeTempDir('dubbing-out-');
28
+
29
+ // Unit of work = (input × chunk × language). Expand every chunk of every input per language to fill one queue.
30
+ const targets = opts.targets ?? [opts.target ?? 'en'];
31
+ const skip = opts.done instanceof Set ? opts.done : new Set(); // resume: already-completed (inputId|index|target) are not submitted
32
+ const taskKey = (t) => `${t.inputId ?? 0}|${t.index}|${t.target}`;
33
+ const mediaKey = (t) => `${t.inputId ?? 0}|${t.index}`;
34
+
35
+ const pending = [];
36
+ for (const c of chunks) for (const target of targets) {
37
+ const t = { ...c, inputId: c.inputId ?? 0, path: c.path ?? c.localPath, target, retries: 0 };
38
+ if (!skip.has(taskKey(t))) pending.push(t);
39
+ }
40
+ const submitted = new Map(); // projectId → task (input × chunk × language)
41
+ const results = new Map(); // taskKey → result
42
+ const mediaByKey = new Map(); // `${inputId}|${index}` → {seq, kind}: uploaded once per chunk, shared across languages
43
+ let stopAll = false;
44
+ let stopReason = null;
45
+ let engineError = null; // unrecoverable engine error message (for reporting upward)
46
+ let backoff = BACKOFF_BASE_MS;
47
+ let lastProgressAt = Date.now(); // time of last progress. Guards on 'no progress' rather than absolute elapsed time.
48
+
49
+ const timeUp = () => Date.now() - lastProgressAt >= MAX_IDLE_MS;
50
+ const setResult = (t, r) => {
51
+ const rec = { inputId: t.inputId ?? 0, index: t.index, target: t.target, ...r };
52
+ results.set(taskKey(t), rec);
53
+ try { onResult(rec); } catch { /* state saving must never break scheduling */ }
54
+ };
55
+
56
+ // On stop_all, stop new submissions and only finalize in-flight (submitted) ones → exit when empty (pending is preserved).
57
+ while ((submitted.size || (!stopAll && pending.length)) && !timeUp()) {
58
+ let progressed = false;
59
+ let blocked = false; // no free slots (external/prior jobs occupy the queue), so new submissions are impossible
60
+
61
+ // ── Submit ── query the queue state and push only as many as there are 'free slots'; if slots free up, add them next round.
62
+ // (on query failure slots=Infinity = push until VT4292 as before = fallback)
63
+ if (!stopAll && pending.length) {
64
+ const q = await getQueueStatus(spaceSeq);
65
+ let slots = q ? q.available : Infinity;
66
+ if (q) log(`Queue ${q.used}/${q.max} — ${q.available} free slots`);
67
+ const keep = [];
68
+ let rejected = false;
69
+ for (const chunk of pending) {
70
+ if (stopAll || rejected || slots <= 0) {
71
+ keep.push(chunk);
72
+ if (slots <= 0 || rejected) blocked = true; // held due to capacity occupancy (distinct from transient-error retry)
73
+ continue;
74
+ }
75
+ try {
76
+ if (chunk.mediaSeq === undefined) {
77
+ let m = mediaByKey.get(mediaKey(chunk));
78
+ if (!m) { m = await upload(toPrepared(chunk), spaceSeq); mediaByKey.set(mediaKey(chunk), m); } // once per chunk (shared across languages)
79
+ chunk.mediaSeq = m.seq;
80
+ chunk.kind = chunk.kind ?? m.kind;
81
+ }
82
+ const [pid] = await requestTranslation(spaceSeq, chunk.mediaSeq, { ...opts, target: chunk.target, title: chunk.title ?? opts.title, kind: chunk.kind });
83
+ if (pid == null) throw new Error('no projectId');
84
+ submitted.set(pid, chunk);
85
+ progressed = true;
86
+ slots -= 1;
87
+ log(`[Input ${chunk.inputId + 1}] segment ${chunk.index + 1}(${chunk.target}) submitted`);
88
+ } catch (e) {
89
+ const code = e instanceof PersoApiError ? e.code : null;
90
+ if (isCreditError(e)) {
91
+ stopAll = true; stopReason = 'credit'; keep.push(chunk);
92
+ log('Usage (credit) shortage — halting new submissions, finishing only in-flight work');
93
+ } else if (QUEUE_FULL_CODES.has(code)) {
94
+ rejected = true; blocked = true; keep.push(chunk); // leave in-flight untouched, stop only this round's new submissions
95
+ log('Queue full — waiting for a free slot, then retrying');
96
+ } else if (code === 'F4008') {
97
+ // local inputs are usually pre-split by resolveChunks → reaching here means an unsplittable case such as external
98
+ setResult(chunk, { status: 'HARD_FAIL', reason: 'too_long' });
99
+ log(`[Input ${chunk.inputId + 1}] segment ${chunk.index + 1} cannot be processed (length)`);
100
+ } else if (chunk.retries < MAX_RETRY) {
101
+ chunk.retries++; keep.push(chunk);
102
+ log(`[Input ${chunk.inputId + 1}] segment ${chunk.index + 1} retrying`);
103
+ } else {
104
+ setResult(chunk, { status: 'HARD_FAIL', reason: 'submit_failed' });
105
+ log(`[Input ${chunk.inputId + 1}] segment ${chunk.index + 1} processing failed`);
106
+ }
107
+ }
108
+ }
109
+ pending.length = 0;
110
+ pending.push(...keep);
111
+ }
112
+
113
+ // ── Polling ──
114
+ if (submitted.size) {
115
+ await sleep(POLL_INTERVAL_MS);
116
+ for (const [pid, chunk] of [...submitted]) {
117
+ if (!submitted.has(pid)) continue; // already handled this round (e.g. via sibling cancel) → prevent double processing
118
+ let st = null;
119
+ try { st = await getStatus(pid, spaceSeq); } catch { /* transient error → next round */ }
120
+ if (!st || st.state === 'processing') {
121
+ // if the server is raising the progress %, treat it as 'progress' and reset the no-progress timer (don't kill slow long videos).
122
+ if (st && typeof st.progress === 'number' && st.progress > (chunk._progress ?? -1)) {
123
+ chunk._progress = st.progress;
124
+ progressed = true;
125
+ }
126
+ continue;
127
+ }
128
+
129
+ submitted.delete(pid);
130
+ progressed = true;
131
+ const tag = `[Input ${chunk.inputId + 1}] segment ${chunk.index + 1}(${chunk.target})`;
132
+ if (st.state === 'complete') {
133
+ const out = join(outDir, `dub_${chunk.inputId}_${String(chunk.index).padStart(3, '0')}_${chunk.target}.mp4`);
134
+ try {
135
+ const dl = await download(pid, spaceSeq, { kind: chunk.kind, outPath: out });
136
+ setResult(chunk, { status: 'OK', projectId: pid, path: out, name: dl.fileName });
137
+ log(`${tag} done`);
138
+ } catch {
139
+ // generation succeeded (has projectSeq) — only download failed. Don't re-dub; mark it as DLFAIL to
140
+ // preserve projectSeq → re-download on resume (no regeneration).
141
+ setResult(chunk, { status: 'DLFAIL', projectId: pid, reason: 'download_failed' });
142
+ log(`${tag} generated (download failed → re-download on resume)`);
143
+ }
144
+ } else if (st.noVoice) {
145
+ // no voice detected. For a split chunk (has endMs), pass the original through → keep silent segments of a long video and dub·merge the rest.
146
+ // For a single (unsplit whole·external) request, fail it so a result byte-identical to the original is not emitted as 'complete'.
147
+ if (chunk.endMs != null) {
148
+ setResult(chunk, { status: 'PASSTHROUGH', projectId: pid, path: chunk.path, reason: 'no_voice' });
149
+ log(`${tag} passthrough (no voice)`);
150
+ } else {
151
+ setResult(chunk, { status: 'HARD_FAIL', projectId: pid, reason: 'no_voice' });
152
+ log(`${tag} no voice detected — nothing to dub`);
153
+ }
154
+ } else if (st.failureReason === 'ENGINE_ERROR') {
155
+ // engine error (unrecoverable) → fail without retry. The same chunk (same mediaSeq) is identical in any language → cancel siblings.
156
+ engineError = st.message ?? engineError ?? 'engine processing error';
157
+ setResult(chunk, { status: 'HARD_FAIL', projectId: pid, reason: st.message ?? 'engine_error' });
158
+ log(`${tag} cannot be processed (engine error): ${st.message ?? ''}`);
159
+ await cancelSiblings(chunk.inputId, chunk.index, pid);
160
+ } else if (chunk.retries < MAX_RETRY) {
161
+ chunk.retries++; pending.push(chunk); // reuse the upload (mediaSeq) to re-translate
162
+ log(`${tag} retrying`);
163
+ } else {
164
+ setResult(chunk, { status: 'HARD_FAIL', projectId: pid, reason: st.message ?? 'failed' });
165
+ log(`${tag} processing failed`);
166
+ }
167
+ }
168
+ } else if (pending.length && !stopAll) {
169
+ if (blocked) {
170
+ // no free slots (external/prior jobs occupy the queue) + no in-flight of ours either: pure waiting.
171
+ // reset the no-progress timer (don't time out while waiting on external jobs) and re-check after 5 minutes.
172
+ lastProgressAt = Date.now();
173
+ log('No free slot in the queue, waiting — re-checking in 5 minutes');
174
+ await sleep(QUEUE_WAIT_MS);
175
+ } else {
176
+ // nothing in-flight but unable to submit (transient error · persistent VT5034) → retry after exponential backoff
177
+ await sleep(backoff);
178
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
179
+ }
180
+ }
181
+
182
+ if (progressed) { backoff = BACKOFF_BASE_MS; lastProgressAt = Date.now(); } // on progress, reset backoff/no-progress timer
183
+ }
184
+
185
+ if (timeUp()) failRemaining('elapsed_exceeded');
186
+
187
+ return {
188
+ results,
189
+ outDir,
190
+ stopped: stopAll,
191
+ stopReason,
192
+ engineError,
193
+ pendingLeft: pending.map(({ retries, mediaSeq, _progress, ...c }) => c), // preserved on stop_all (for resume). mediaSeq removed → re-upload.
194
+ };
195
+
196
+ function failRemaining(reason) {
197
+ for (const c of pending) if (!results.has(taskKey(c))) setResult(c, { status: 'HARD_FAIL', reason });
198
+ // In-flight projects may still finish server-side (credits already spent) → keep the projectSeq as DLFAIL
199
+ // so resume re-downloads the result instead of re-dubbing (no double billing). A confirmed server-side
200
+ // failure is converted back to a re-dub by the resume flow.
201
+ for (const [pid, c] of submitted) if (!results.has(taskKey(c))) setResult(c, { status: 'DLFAIL', projectId: pid, reason });
202
+ submitted.clear();
203
+ pending.length = 0;
204
+ }
205
+
206
+ // a chunk with an engine error yields the same result in any language → cancel in-flight siblings (same input·chunk, other languages) + drop pending ones.
207
+ async function cancelSiblings(inputId, index, exceptPid) {
208
+ for (const [pid2, t] of [...submitted]) {
209
+ if (t.inputId === inputId && t.index === index && pid2 !== exceptPid) {
210
+ await cancel(pid2, spaceSeq);
211
+ submitted.delete(pid2);
212
+ setResult(t, { status: 'HARD_FAIL', projectId: pid2, reason: 'engine_error' });
213
+ log(`[Input ${t.inputId + 1}] segment ${t.index + 1}(${t.target}) canceled (same chunk engine error)`);
214
+ }
215
+ }
216
+ for (let i = pending.length - 1; i >= 0; i--) {
217
+ if (pending[i].inputId === inputId && pending[i].index === index) {
218
+ setResult(pending[i], { status: 'HARD_FAIL', reason: 'engine_error' });
219
+ pending.splice(i, 1);
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ function toPrepared(chunk) {
226
+ if (chunk.source === 'external') return { source: 'external', sourceUrl: chunk.sourceUrl };
227
+ return { source: 'local', localPath: chunk.path, originalName: chunk.originalName ?? basename(chunk.path) };
228
+ }
@@ -0,0 +1,51 @@
1
+ // spaceSeq resolution. Commonly needed by validate, translate, and quota.
2
+ import { get } from './http_client.mjs';
3
+
4
+ let _cache = null;
5
+
6
+ export async function listSpaces() {
7
+ if (_cache) return _cache;
8
+ const res = await get('/portal/api/v1/spaces');
9
+ _cache = res?.result ?? [];
10
+ return _cache;
11
+ }
12
+
13
+ /** Spaces where dubbing can run (video_translator; falls back to all), with display names for the user to choose from. */
14
+ export async function dubbingSpaces() {
15
+ const spaces = await listSpaces();
16
+ if (!spaces.length) throw new Error('No accessible space.');
17
+ const vt = spaces.filter((s) => s.serviceType === 'video_translator');
18
+ return (vt.length ? vt : spaces).map((s) => ({
19
+ seq: s.spaceSeq,
20
+ name: s.spaceName ?? s.name ?? `space ${s.spaceSeq}`,
21
+ tier: s.tier ?? null,
22
+ }));
23
+ }
24
+
25
+ /** Non-interactive spaceSeq resolution: env pin → the only space. With several spaces the user must choose
26
+ * (dubbing.mjs asks by name); here we can only fail with guidance. */
27
+ export async function resolveSpace() {
28
+ const pinned = Number(process.env.PERSO_SPACE_SEQ);
29
+ if (pinned) return pinned;
30
+ const spaces = await dubbingSpaces();
31
+ if (spaces.length === 1) return spaces[0].seq;
32
+ throw new Error(
33
+ 'Several spaces are available — set PERSO_SPACE_SEQ or pass --space <seq>:\n' +
34
+ spaces.map((s) => ` ${s.seq}: ${s.name}`).join('\n'),
35
+ );
36
+ }
37
+
38
+ /** Query plan/quota → { planTier, remainingQuota, resetDateTime }. Returns null on failure. */
39
+ export async function getPlanStatus(spaceSeq) {
40
+ try {
41
+ const res = await get(`/video-translator/api/v1/projects/spaces/${spaceSeq}/plan/status`);
42
+ const r = res?.result ?? res ?? {};
43
+ return {
44
+ planTier: r.planTier ?? null,
45
+ remainingQuota: r.remainingQuota?.remainingQuota ?? r.remainingQuota ?? null,
46
+ resetDateTime: r.resetDateTime ?? null,
47
+ };
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
@@ -0,0 +1,178 @@
1
+ // Upload-first + length/size cap: try uploading the whole file first.
2
+ // - success → single chunk (already uploaded). ffmpeg/ffprobe not used.
3
+ // - F4008 (length) → lossless segment split by maxLengthMs
4
+ // - F4004 (size>2GB) → lossless segment split to a length that fits ~2GB (-c copy → keep original bitrate)
5
+ // Splitting uses the segment muxer (-c copy + -reset_timestamps 1, SEG=cap−GOP−margin). Re-encode fallback only when lossless is impossible.
6
+ // Each split piece is uploaded again → if still over the limit, split recursively. Leaf pieces hold a mediaSeq.
7
+ import { stat } from 'node:fs/promises';
8
+ import { extname, join, basename } from 'node:path';
9
+ import { PersoApiError } from './http_client.mjs';
10
+ import { upload } from './api_adapter.mjs';
11
+ import { probe, cut, cutCopy, tempDir, probeGop, probeHasVideo, segmentCopy, segmentReencode } from './ffmpeg.mjs';
12
+ import { ensureFfmpeg } from '../scripts/check_deps.mjs';
13
+
14
+ // Safe margin below the upload size limit (API 2GB). Overridable via env.
15
+ const SIZE_CAP = Number(process.env.PERSO_SIZE_CAP_BYTES) || Math.floor(1.9 * 1024 ** 3);
16
+ const MAX_DEPTH = 4;
17
+ const SEG_MARGIN_MS = 2000; // SEG = cap − GOP − margin: a safety buffer to stay below the cap
18
+ const MIN_SEG_MS = 3000; // if SEG is smaller than this (GOP too large relative to the cap), lossless is impossible → re-encode fallback
19
+
20
+ /** @returns {{chunks:Array<{index,source,path?,sourceUrl?,mediaSeq?,startMs?,endMs?}>, notice:string|null}} */
21
+ export async function resolveChunks(prepared, spaceSeq, hooks = {}) {
22
+ const log = hooks.log ?? (() => {});
23
+ const notify = hooks.notify ?? (() => {}); // user-facing milestones (split start, etc.)
24
+ if (prepared.source === 'external') {
25
+ return { chunks: [{ index: 0, source: 'external', sourceUrl: prepared.sourceUrl }], notice: null };
26
+ }
27
+
28
+ const localPath = prepared.localPath ?? prepared.path;
29
+ const leaves = await uploadOrSplit(localPath, spaceSeq, 0, 0, null, log, notify);
30
+ // carry each leaf's absolute boundaries (startMs/endMs) on the chunk → on resume, re-cut only that segment from the original.
31
+ // when split, number the title _01,_02… → in the Perso project list, distinguish pieces of the same original (a single piece keeps the original name).
32
+ const baseTitle = (prepared.originalName ?? basename(localPath)).replace(/\.[^.]+$/, '');
33
+ const chunks = leaves.map((l, i) => ({
34
+ index: i, source: 'local', path: l.path, mediaSeq: l.mediaSeq, kind: l.kind, startMs: l.startMs, endMs: l.endMs,
35
+ title: leaves.length > 1 ? `${baseTitle}_${String(i + 1).padStart(2, '0')}` : baseTitle,
36
+ }));
37
+ const notice = chunks.length > 1 ? `Auto-split: ${chunks.length} parts (length/size limit)` : null;
38
+ return { chunks, notice };
39
+ }
40
+
41
+ /**
42
+ * Try to upload → on F4008/F4004, split and recurse into each piece. A successful leaf is {path, mediaSeq, startMs, endMs}.
43
+ * startMs/spanMs are absolute positions relative to the original (an unsplit whole leaf has endMs=null).
44
+ * Side optimization: a size overflow can be known in advance via local stat, so split a huge file directly instead of uploading it whole.
45
+ */
46
+ async function uploadOrSplit(localPath, spaceSeq, depth, startMs = 0, spanMs = null, log = () => {}, notify = () => {}) {
47
+ if (depth < MAX_DEPTH) {
48
+ const { size } = await stat(localPath);
49
+ if (size > SIZE_CAP) { // size overflow → split before uploading
50
+ log('The file is large, so the video is processed in parts. Cutting and merging takes a little while.');
51
+ return splitAndRecurse(localPath, spaceSeq, depth, startMs, Infinity, log, notify);
52
+ }
53
+ }
54
+ try {
55
+ if (depth === 0) log('Uploading video... (large videos take a while)');
56
+ const { seq: mediaSeq, kind } = await upload({ source: 'local', localPath, originalName: basename(localPath) }, spaceSeq);
57
+ return [{ path: localPath, mediaSeq, kind, startMs, endMs: spanMs == null ? null : startMs + spanMs }];
58
+ } catch (e) {
59
+ const code = e instanceof PersoApiError ? e.code : null;
60
+ if (code !== 'F4008' && code !== 'F4004') throw e; // errors other than length/size are rethrown as-is
61
+ if (depth >= MAX_DEPTH) throw new Error(`Split limit exceeded (depth ${depth}) — cannot upload`);
62
+ log('The video exceeds the length/size limit, so it is processed in parts. Cutting and merging takes a little while.');
63
+ const lengthCapMs = code === 'F4008' ? Number(e.data?.maxLengthMs) : Infinity;
64
+ return splitAndRecurse(localPath, spaceSeq, depth, startMs, lengthCapMs, log, notify);
65
+ }
66
+ }
67
+
68
+ /** Split localPath by the length/size limit and recursively upload each piece. baseStartMs is the absolute offset relative to the original. */
69
+ async function splitAndRecurse(localPath, spaceSeq, depth, baseStartMs, lengthCapMs, log = () => {}, notify = () => {}) {
70
+ ensureFfmpeg(); // install only when cutting is confirmed
71
+ if (depth === 0) notify('Splitting — the video exceeds the length/size limit, so it is processed in parts.'); // user-facing
72
+ const { durationMs, sizeBytes } = await probe(localPath);
73
+ if (!durationMs) throw new Error('Could not measure the video length.');
74
+
75
+ const capMs = computeChunkMs(durationMs, sizeBytes, lengthCapMs); // the smaller of the length/size caps
76
+ const parts = await splitByMaxLen(localPath, durationMs, capMs, SIZE_CAP, log);
77
+
78
+ const out = [];
79
+ for (let i = 0; i < parts.length; i++) {
80
+ log(`Uploading piece ${i + 1}/${parts.length}...`);
81
+ const p = parts[i];
82
+ out.push(...(await uploadOrSplit(p.path, spaceSeq, depth + 1, baseStartMs + p.startMs, p.durationMs, log, notify)));
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** For resume: re-cut the [startMs,endMs) segment from the original at the same boundaries as the split, into a temp file. */
88
+ export async function recutChunk(localPath, startMs, endMs) {
89
+ ensureFfmpeg();
90
+ const dir = await tempDir();
91
+ const ext = extname(localPath) || '.mp4';
92
+ const out = join(dir, `recut_${startMs}_${endMs}${ext}`);
93
+ try {
94
+ await cutCopy(localPath, startMs, endMs, out); // lossless first (boundaries are the keyframes from the split)
95
+ const { durationMs } = await probe(out).catch(() => ({}));
96
+ if (durationMs && durationMs > 200) return out;
97
+ } catch { /* fallback ↓ */ }
98
+ await cut(localPath, startMs, endMs, out, SIZE_CAP); // re-encode fallback (frame-accurate)
99
+ return out;
100
+ }
101
+
102
+ /** Determine the piece length (ms) as the smaller of the length cap and size cap. Independent of resolution·fps (cuts by time only). */
103
+ export function computeChunkMs(durationMs, sizeBytes, lengthCapMs = Infinity, sizeCap = SIZE_CAP) {
104
+ let chunkMs = durationMs;
105
+ if (Number.isFinite(lengthCapMs) && lengthCapMs > 0) chunkMs = Math.min(chunkMs, lengthCapMs);
106
+ if (sizeBytes && sizeBytes > sizeCap) {
107
+ const sizeMs = Math.floor((durationMs * sizeCap) / sizeBytes);
108
+ if (sizeMs > 0) chunkMs = Math.min(chunkMs, sizeMs);
109
+ }
110
+ if (chunkMs >= durationMs) chunkMs = Math.floor(durationMs / 2); // if it can't be narrowed, halve it (avoid infinite loop)
111
+ return Math.max(1, chunkMs);
112
+ }
113
+
114
+ /**
115
+ * Lossless segment split (-c copy) to fit the capMs limit. Set SEG = capMs − GOP − margin so that
116
+ * even when cut at keyframe boundaries, no piece exceeds the limit. Re-encode fallback only when lossless is impossible/exceeded.
117
+ * @returns {Array<{index,path,startMs,durationMs}>}
118
+ */
119
+ export async function splitByMaxLen(path, durationMs, capMs, maxBytes, log = () => {}) {
120
+ const ext = extname(path) || '.mp4';
121
+ const audioOnly = !(await probeHasVideo(path)); // an audio file has no video stream
122
+ let paths = null;
123
+
124
+ if (audioOnly) {
125
+ // audio: keyframe/GOP-independent (can cut anywhere per frame) → SEG = cap − margin. -c copy lossless (container-matched codec fallback).
126
+ const segMs = Math.max(MIN_SEG_MS, capMs - SEG_MARGIN_MS);
127
+ log(`Lossless split in progress... (audio, piece ≈${Math.round(segMs / 1000)}s)`);
128
+ try {
129
+ paths = await segmentCopy(path, segMs, ext, { audioOnly: true });
130
+ if (await anyOverCap(paths, capMs)) paths = null;
131
+ } catch {
132
+ paths = null;
133
+ }
134
+ if (!paths || !paths.length) {
135
+ log('Audio re-encode split (fallback)');
136
+ paths = await segmentReencode(path, segMs, ext, maxBytes, { audioOnly: true });
137
+ }
138
+ } else {
139
+ // video: cut only at keyframe boundaries → SEG = cap − GOP − margin.
140
+ const gopMs = await probeGop(path);
141
+ const margin = Math.max(SEG_MARGIN_MS, gopMs); // margin against GOP under-measurement
142
+ const segMs = capMs - gopMs - margin;
143
+ if (segMs >= MIN_SEG_MS) {
144
+ log(`Lossless split in progress... (GOP ≈${Math.round(gopMs)}ms, piece ≈${Math.round(segMs / 1000)}s)`);
145
+ try {
146
+ paths = await segmentCopy(path, segMs, ext);
147
+ if (await anyOverCap(paths, capMs)) { log('Some pieces exceed the limit → retrying with re-encode split'); paths = null; }
148
+ } catch {
149
+ paths = null; // -c copy failed (container/codec) → fallback
150
+ }
151
+ }
152
+ if (!paths || !paths.length) {
153
+ const reSeg = Math.max(MIN_SEG_MS, capMs - margin); // re-encode forces a keyframe at the boundary → GOP-independent
154
+ log(`Re-encode split (fallback) in progress... (piece ≈${Math.round(reSeg / 1000)}s)`);
155
+ paths = await segmentReencode(path, reSeg, ext, maxBytes);
156
+ }
157
+ }
158
+
159
+ // measure each piece's actual length → startMs (cumulative) relative to the original + durationMs (used as resume re-cut boundaries)
160
+ const chunks = [];
161
+ let acc = 0;
162
+ for (let i = 0; i < paths.length; i++) {
163
+ const { durationMs: d } = await probe(paths[i]).catch(() => ({}));
164
+ const dur = d ?? Math.max(1, Math.min(capMs, durationMs - acc));
165
+ chunks.push({ index: i, path: paths[i], startMs: acc, durationMs: dur });
166
+ acc += dur;
167
+ }
168
+ return chunks;
169
+ }
170
+
171
+ /** True if any piece exceeds capMs (detects the rare case where a lossless keyframe cut overshot the limit). */
172
+ async function anyOverCap(paths, capMs) {
173
+ for (const p of paths) {
174
+ const { durationMs } = await probe(p).catch(() => ({}));
175
+ if (durationMs && durationMs > capMs) return true;
176
+ }
177
+ return false;
178
+ }
@@ -0,0 +1,21 @@
1
+ // Temp working-folder tracking — bulk-deletes temp artifacts created by splitting/scheduling/merging/downloading on exit.
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const _dirs = new Set();
7
+
8
+ /** Creates a tracked temp folder. It is bulk-deleted by cleanupTempDirs(). */
9
+ export async function makeTempDir(prefix = 'dubbing-') {
10
+ const dir = await mkdtemp(join(tmpdir(), prefix));
11
+ _dirs.add(dir);
12
+ return dir;
13
+ }
14
+
15
+ /** Deletes all temp folders created so far (failures are ignored). */
16
+ export async function cleanupTempDirs() {
17
+ for (const dir of _dirs) {
18
+ try { await rm(dir, { recursive: true, force: true }); } catch { /* ignore */ }
19
+ }
20
+ _dirs.clear();
21
+ }
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // Check ffmpeg/ffprobe. If missing, attempt auto-install without prompting (the OS asks for approval if elevation is needed).
3
+ import { spawnSync } from 'node:child_process';
4
+ import { realpathSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ function has(bin) {
8
+ const probe = process.platform === 'win32' ? 'where' : 'which';
9
+ return spawnSync(probe, [bin], { stdio: 'ignore' }).status === 0;
10
+ }
11
+
12
+ function installCmd() {
13
+ if (process.platform === 'win32') {
14
+ if (has('winget')) return ['winget', ['install', '-e', '--id', 'Gyan.FFmpeg', '--accept-source-agreements', '--accept-package-agreements']];
15
+ if (has('choco')) return ['choco', ['install', 'ffmpeg', '-y']];
16
+ if (has('scoop')) return ['scoop', ['install', 'ffmpeg']];
17
+ } else if (process.platform === 'darwin') {
18
+ if (has('brew')) return ['brew', ['install', 'ffmpeg']];
19
+ } else {
20
+ if (has('apt-get')) return ['sudo', ['apt-get', 'install', '-y', 'ffmpeg']];
21
+ if (has('dnf')) return ['sudo', ['dnf', 'install', '-y', 'ffmpeg']];
22
+ if (has('pacman')) return ['sudo', ['pacman', '-S', '--noconfirm', 'ffmpeg']];
23
+ }
24
+ return null;
25
+ }
26
+
27
+ /** Returns true if both ffmpeg and ffprobe are present. Otherwise auto-installs and re-checks. Throws if not possible. */
28
+ export function ensureFfmpeg() {
29
+ if (has('ffmpeg') && has('ffprobe')) return true;
30
+
31
+ const cmd = installCmd();
32
+ if (!cmd) {
33
+ throw new Error('ffmpeg/ffprobe not found and no auto-installable package manager available — https://ffmpeg.org/download.html');
34
+ }
35
+ const [bin, args] = cmd;
36
+ const r = spawnSync(bin, args, { stdio: 'inherit' }); // the OS prompts if elevation is needed
37
+ if (r.status !== 0) throw new Error('ffmpeg auto-install failed');
38
+
39
+ if (!(has('ffmpeg') && has('ffprobe'))) {
40
+ throw new Error('ffmpeg/ffprobe still not found after install. Check your PATH.');
41
+ }
42
+ return true;
43
+ }
44
+
45
+ const isMain = process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
46
+ if (isMain) {
47
+ try {
48
+ ensureFfmpeg();
49
+ console.log('ffmpeg/ffprobe OK');
50
+ } catch (e) {
51
+ console.error(e.message);
52
+ process.exit(1);
53
+ }
54
+ }