dsh-file-convert 0.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/LICENSE +28 -0
- package/README.md +225 -0
- package/README.zh-CN.md +203 -0
- package/cordis.patch.yml +3 -0
- package/lib/config.d.ts +36 -0
- package/lib/config.js +17 -0
- package/lib/core/binaries/cache.d.ts +13 -0
- package/lib/core/binaries/cache.js +137 -0
- package/lib/core/binaries/download.d.ts +21 -0
- package/lib/core/binaries/download.js +140 -0
- package/lib/core/binary.d.ts +7 -0
- package/lib/core/binary.js +29 -0
- package/lib/core/converters/data.d.ts +14 -0
- package/lib/core/converters/data.js +145 -0
- package/lib/core/converters/image.d.ts +12 -0
- package/lib/core/converters/image.js +64 -0
- package/lib/core/converters/media.d.ts +21 -0
- package/lib/core/converters/media.js +135 -0
- package/lib/core/converters/office.d.ts +33 -0
- package/lib/core/converters/office.js +206 -0
- package/lib/core/converters/pdf-env.d.ts +1 -0
- package/lib/core/converters/pdf-env.js +8 -0
- package/lib/core/converters/pdf.d.ts +22 -0
- package/lib/core/converters/pdf.js +316 -0
- package/lib/core/detect.d.ts +16 -0
- package/lib/core/detect.js +121 -0
- package/lib/core/errors.d.ts +5 -0
- package/lib/core/errors.js +16 -0
- package/lib/core/formats.d.ts +11 -0
- package/lib/core/formats.js +60 -0
- package/lib/core/index.d.ts +25 -0
- package/lib/core/index.js +52 -0
- package/lib/core/inspect.d.ts +13 -0
- package/lib/core/inspect.js +134 -0
- package/lib/core/ocr.d.ts +38 -0
- package/lib/core/ocr.js +152 -0
- package/lib/core/optimizers.d.ts +29 -0
- package/lib/core/optimizers.js +275 -0
- package/lib/core/paths.d.ts +11 -0
- package/lib/core/paths.js +19 -0
- package/lib/core/router.d.ts +64 -0
- package/lib/core/router.js +288 -0
- package/lib/core/types.d.ts +199 -0
- package/lib/core/types.js +8 -0
- package/lib/core/utils/exec.d.ts +38 -0
- package/lib/core/utils/exec.js +89 -0
- package/lib/core/utils/pages.d.ts +12 -0
- package/lib/core/utils/pages.js +46 -0
- package/lib/format.d.ts +23 -0
- package/lib/format.js +84 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +51 -0
- package/lib/tools/batch-convert.d.ts +5 -0
- package/lib/tools/batch-convert.js +155 -0
- package/lib/tools/convert-file.d.ts +3 -0
- package/lib/tools/convert-file.js +57 -0
- package/lib/tools/inspect-file.d.ts +3 -0
- package/lib/tools/inspect-file.js +29 -0
- package/lib/tools/install-media.d.ts +9 -0
- package/lib/tools/install-media.js +55 -0
- package/lib/tools/install-ocr.d.ts +7 -0
- package/lib/tools/install-ocr.js +43 -0
- package/lib/tools/list-conversions.d.ts +2 -0
- package/lib/tools/list-conversions.js +18 -0
- package/lib/tools/optimize-file.d.ts +3 -0
- package/lib/tools/optimize-file.js +91 -0
- package/package.json +66 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import sharp from 'sharp';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { parse as csvParse } from 'csv-parse/sync';
|
|
5
|
+
import { probeMedia } from './utils/exec.js';
|
|
6
|
+
import './converters/pdf-env.js';
|
|
7
|
+
/**
|
|
8
|
+
* Structured file facts so an agent can decide before converting.
|
|
9
|
+
* Inspect is informational: parse problems degrade to `kind: 'unknown'`
|
|
10
|
+
* (or `probeUnavailable` for media) instead of failing.
|
|
11
|
+
*/
|
|
12
|
+
export async function inspectFile(input, detection, bytes, media) {
|
|
13
|
+
switch (detection.format) {
|
|
14
|
+
case 'png':
|
|
15
|
+
case 'jpg':
|
|
16
|
+
case 'webp':
|
|
17
|
+
case 'svg':
|
|
18
|
+
case 'gif':
|
|
19
|
+
return inspectImage(input, detection.format, bytes);
|
|
20
|
+
case 'pdf':
|
|
21
|
+
return inspectPdf(input, bytes);
|
|
22
|
+
case 'mp4':
|
|
23
|
+
case 'mov':
|
|
24
|
+
case 'mp3':
|
|
25
|
+
case 'wav':
|
|
26
|
+
return inspectMedia(input, detection.format, bytes, media);
|
|
27
|
+
case 'json':
|
|
28
|
+
case 'yaml':
|
|
29
|
+
case 'csv':
|
|
30
|
+
case 'txt':
|
|
31
|
+
return inspectData(input, detection.format, bytes);
|
|
32
|
+
default:
|
|
33
|
+
return { kind: 'unknown', bytes, mime: detection.mime };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function inspectImage(input, format, bytes) {
|
|
37
|
+
try {
|
|
38
|
+
const meta = await sharp(input).metadata();
|
|
39
|
+
return {
|
|
40
|
+
kind: 'image',
|
|
41
|
+
format,
|
|
42
|
+
width: meta.width ?? 0,
|
|
43
|
+
height: meta.height ?? 0,
|
|
44
|
+
channels: meta.channels,
|
|
45
|
+
bytes,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { kind: 'unknown', bytes };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function inspectMedia(input, format, bytes, probe) {
|
|
53
|
+
const base = { kind: 'media', format, bytes };
|
|
54
|
+
if (!probe)
|
|
55
|
+
return { ...base, probeUnavailable: true };
|
|
56
|
+
const parsed = await probeMedia(probe.ffprobePath, input, probe.timeoutMs, probe.signal).catch(() => null);
|
|
57
|
+
if (!parsed)
|
|
58
|
+
return { ...base, probeUnavailable: true };
|
|
59
|
+
const video = parsed.streams?.find((s) => s.codec_type === 'video');
|
|
60
|
+
const audio = parsed.streams?.find((s) => s.codec_type === 'audio');
|
|
61
|
+
return {
|
|
62
|
+
...base,
|
|
63
|
+
durationSec: parsed.format?.duration ? Number.parseFloat(parsed.format.duration) : undefined,
|
|
64
|
+
width: video?.width,
|
|
65
|
+
height: video?.height,
|
|
66
|
+
fps: parseFps(video?.r_frame_rate),
|
|
67
|
+
audioCodec: audio?.codec_name,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** '30/1' -> 30; '2997/100' -> 29.97; garbage/zero denominator -> undefined. */
|
|
71
|
+
function parseFps(rFrameRate) {
|
|
72
|
+
if (!rFrameRate)
|
|
73
|
+
return undefined;
|
|
74
|
+
const [num, den] = rFrameRate.split('/', 2).map(Number);
|
|
75
|
+
if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0)
|
|
76
|
+
return undefined;
|
|
77
|
+
const fps = num / den;
|
|
78
|
+
return Number.isFinite(fps) && fps > 0 ? Number(fps.toFixed(3)) : undefined;
|
|
79
|
+
}
|
|
80
|
+
async function inspectPdf(input, bytes) {
|
|
81
|
+
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
|
82
|
+
try {
|
|
83
|
+
const data = new Uint8Array(await fs.readFile(input));
|
|
84
|
+
const doc = await getDocument({ data, verbosity: 0 }).promise;
|
|
85
|
+
try {
|
|
86
|
+
let chars = 0;
|
|
87
|
+
for (let n = 1; n <= Math.min(3, doc.numPages); n++) {
|
|
88
|
+
const page = await doc.getPage(n);
|
|
89
|
+
const content = await page.getTextContent();
|
|
90
|
+
for (const item of content.items)
|
|
91
|
+
chars += item.str?.trim().length ?? 0;
|
|
92
|
+
page.cleanup();
|
|
93
|
+
}
|
|
94
|
+
const pagesInspected = Math.min(3, doc.numPages);
|
|
95
|
+
return {
|
|
96
|
+
kind: 'pdf',
|
|
97
|
+
pages: doc.numPages,
|
|
98
|
+
encrypted: false,
|
|
99
|
+
likelyScanned: chars / pagesInspected < 40,
|
|
100
|
+
bytes,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await doc.cleanup().catch(() => undefined);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const name = err instanceof Error ? err.name : '';
|
|
109
|
+
if (name === 'PasswordException') {
|
|
110
|
+
return { kind: 'pdf', pages: 0, encrypted: true, likelyScanned: false, bytes };
|
|
111
|
+
}
|
|
112
|
+
return { kind: 'unknown', bytes };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function inspectData(input, format, bytes) {
|
|
116
|
+
try {
|
|
117
|
+
const raw = (await fs.readFile(input, 'utf8')).replace(/^\uFEFF/, '');
|
|
118
|
+
if (format === 'txt')
|
|
119
|
+
return { kind: 'data', format, bytes };
|
|
120
|
+
if (format === 'json') {
|
|
121
|
+
const value = JSON.parse(raw);
|
|
122
|
+
return { kind: 'data', format, records: Array.isArray(value) ? value.length : 1, bytes };
|
|
123
|
+
}
|
|
124
|
+
if (format === 'yaml') {
|
|
125
|
+
const value = yaml.load(raw);
|
|
126
|
+
return { kind: 'data', format, records: Array.isArray(value) ? value.length : 1, bytes };
|
|
127
|
+
}
|
|
128
|
+
const rows = csvParse(raw, { bom: true, columns: true, skip_empty_lines: true });
|
|
129
|
+
return { kind: 'data', format, records: rows.length, bytes };
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { kind: 'unknown', bytes };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { BinaryDependency, ConvertContext, Logger } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Metadata for the tesseract.js language data, used in dependency errors.
|
|
4
|
+
* Conversions NEVER download it implicitly: without a cached pack they fail
|
|
5
|
+
* with guidance pointing at install_ocr_dependencies.
|
|
6
|
+
*/
|
|
7
|
+
export declare const OCR_LANGUAGE_DATA: BinaryDependency;
|
|
8
|
+
export declare const TESSERACT: BinaryDependency;
|
|
9
|
+
export interface OcrEngine {
|
|
10
|
+
name: string;
|
|
11
|
+
recognizePng(png: Buffer, lang: string, ctx: ConvertContext): Promise<string>;
|
|
12
|
+
}
|
|
13
|
+
export declare function tessdataDir(): string;
|
|
14
|
+
/** True when every language of the set already sits in the plugin cache. */
|
|
15
|
+
export declare function ocrLanguagesCached(lang: string): Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* Explicitly download the language data for `lang` by warming a worker.
|
|
18
|
+
* Called only from install_ocr_dependencies (user consented).
|
|
19
|
+
*/
|
|
20
|
+
export declare function installOcrLanguages(lang: string, ctx: {
|
|
21
|
+
logger: Logger;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}): Promise<{
|
|
24
|
+
files: string[];
|
|
25
|
+
bytes: number;
|
|
26
|
+
}>;
|
|
27
|
+
/** Thrown by engines when the requested OCR language data is not available. */
|
|
28
|
+
export declare class OcrLanguageMissingError extends Error {
|
|
29
|
+
readonly lang: string;
|
|
30
|
+
readonly detail?: string | undefined;
|
|
31
|
+
constructor(lang: string, detail?: string | undefined);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Pick an OCR engine: a locally installed Tesseract CLI first (fast, uses the
|
|
35
|
+
* system's trained language data), then the bundled tesseract.js as a pure-npm
|
|
36
|
+
* fallback. null = nothing usable (the caller reports how to fix it).
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveOcrEngine(resolve: (dep: BinaryDependency) => Promise<string | null>, logger: Logger): Promise<OcrEngine | null>;
|
package/lib/core/ocr.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ExecError, execTool } from './utils/exec.js';
|
|
5
|
+
import { cacheDir } from './binaries/cache.js';
|
|
6
|
+
/**
|
|
7
|
+
* Metadata for the tesseract.js language data, used in dependency errors.
|
|
8
|
+
* Conversions NEVER download it implicitly: without a cached pack they fail
|
|
9
|
+
* with guidance pointing at install_ocr_dependencies.
|
|
10
|
+
*/
|
|
11
|
+
export const OCR_LANGUAGE_DATA = {
|
|
12
|
+
name: 'ocr-language-data',
|
|
13
|
+
displayName: 'OCR language data (tesseract.js)',
|
|
14
|
+
commands: [],
|
|
15
|
+
installHint: {
|
|
16
|
+
win32: 'run the install_ocr_dependencies tool',
|
|
17
|
+
darwin: 'run the install_ocr_dependencies tool',
|
|
18
|
+
linux: 'run the install_ocr_dependencies tool',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
export const TESSERACT = {
|
|
22
|
+
name: 'tesseract',
|
|
23
|
+
displayName: 'Tesseract OCR',
|
|
24
|
+
commands: ['tesseract'],
|
|
25
|
+
configKey: 'tesseractPath',
|
|
26
|
+
extraPaths: {
|
|
27
|
+
win32: ['C:\\Program Files\\Tesseract-OCR\\tesseract.exe'],
|
|
28
|
+
darwin: ['/opt/homebrew/bin/tesseract', '/usr/local/bin/tesseract'],
|
|
29
|
+
linux: ['/usr/bin/tesseract', '/usr/local/bin/tesseract'],
|
|
30
|
+
},
|
|
31
|
+
installHint: {
|
|
32
|
+
win32: 'winget install UB-Mannheim.TesseractOCR (tick the chi_sim language component)',
|
|
33
|
+
darwin: 'brew install tesseract tesseract-lang',
|
|
34
|
+
linux: 'sudo apt install tesseract-ocr tesseract-ocr-chi-sim',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
/** One warmed tesseract.js worker per language set, reused across pages. */
|
|
38
|
+
const jsWorkers = new Map();
|
|
39
|
+
async function jsWorker(lang) {
|
|
40
|
+
let pending = jsWorkers.get(lang);
|
|
41
|
+
if (!pending) {
|
|
42
|
+
pending = (async () => {
|
|
43
|
+
const { createWorker } = await import('tesseract.js');
|
|
44
|
+
// Language data (~10-30 MB per language) downloads into the plugin
|
|
45
|
+
// cache; only reached via install_ocr_dependencies' explicit consent.
|
|
46
|
+
const cachePath = path.join(cacheDir(), 'tessdata');
|
|
47
|
+
await fs.mkdir(cachePath, { recursive: true });
|
|
48
|
+
return createWorker(lang, 1, { cachePath });
|
|
49
|
+
})();
|
|
50
|
+
jsWorkers.set(lang, pending);
|
|
51
|
+
pending.catch(() => jsWorkers.delete(lang));
|
|
52
|
+
}
|
|
53
|
+
return pending;
|
|
54
|
+
}
|
|
55
|
+
export function tessdataDir() {
|
|
56
|
+
return path.join(cacheDir(), 'tessdata');
|
|
57
|
+
}
|
|
58
|
+
/** True when every language of the set already sits in the plugin cache. */
|
|
59
|
+
export async function ocrLanguagesCached(lang) {
|
|
60
|
+
let entries = [];
|
|
61
|
+
try {
|
|
62
|
+
entries = await fs.readdir(tessdataDir());
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return lang.split('+').every((l) => entries.some((e) => e.startsWith(`${l}.traineddata`)));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Explicitly download the language data for `lang` by warming a worker.
|
|
71
|
+
* Called only from install_ocr_dependencies (user consented).
|
|
72
|
+
*/
|
|
73
|
+
export async function installOcrLanguages(lang, ctx) {
|
|
74
|
+
void ctx.signal; // tesseract.js cannot abort mid-download; the tool's timeout applies
|
|
75
|
+
await jsWorker(lang); // creating the worker downloads the language data
|
|
76
|
+
const entries = await fs.readdir(tessdataDir()).catch(() => []);
|
|
77
|
+
const files = [];
|
|
78
|
+
let bytes = 0;
|
|
79
|
+
for (const l of lang.split('+')) {
|
|
80
|
+
const file = entries.find((e) => e.startsWith(`${l}.traineddata`));
|
|
81
|
+
if (!file) {
|
|
82
|
+
throw new Error(`Language data for '${l}' did not download; check network access to the tesseract.js CDN.`);
|
|
83
|
+
}
|
|
84
|
+
const stat = await fs.stat(path.join(tessdataDir(), file));
|
|
85
|
+
files.push(file);
|
|
86
|
+
bytes += stat.size;
|
|
87
|
+
}
|
|
88
|
+
return { files, bytes };
|
|
89
|
+
}
|
|
90
|
+
/** Thrown by engines when the requested OCR language data is not available. */
|
|
91
|
+
export class OcrLanguageMissingError extends Error {
|
|
92
|
+
lang;
|
|
93
|
+
detail;
|
|
94
|
+
constructor(lang, detail) {
|
|
95
|
+
super(`OCR language data for '${lang}' is not available.`);
|
|
96
|
+
this.lang = lang;
|
|
97
|
+
this.detail = detail;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Pick an OCR engine: a locally installed Tesseract CLI first (fast, uses the
|
|
102
|
+
* system's trained language data), then the bundled tesseract.js as a pure-npm
|
|
103
|
+
* fallback. null = nothing usable (the caller reports how to fix it).
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveOcrEngine(resolve, logger) {
|
|
106
|
+
const cli = await resolve(TESSERACT);
|
|
107
|
+
if (cli) {
|
|
108
|
+
logger.debug(`ocr engine: tesseract CLI at ${cli}`);
|
|
109
|
+
return {
|
|
110
|
+
name: 'tesseract-cli',
|
|
111
|
+
async recognizePng(png, lang, ctx) {
|
|
112
|
+
const tmp = path.join(os.tmpdir(), `dsh-ocr-${Date.now()}-${Math.random().toString(36).slice(2)}.png`);
|
|
113
|
+
await fs.writeFile(tmp, png);
|
|
114
|
+
try {
|
|
115
|
+
const { stdout } = await execTool(cli, [tmp, 'stdout', '-l', lang], {
|
|
116
|
+
timeoutMs: ctx.timeoutMs,
|
|
117
|
+
signal: ctx.signal,
|
|
118
|
+
maxStderrBytes: 8 * 1024,
|
|
119
|
+
});
|
|
120
|
+
return stdout;
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
// A CLI without the requested traineddata should read as a missing
|
|
124
|
+
// dependency, not as a generic conversion failure.
|
|
125
|
+
if (err instanceof ExecError && err.code === 'failed' && /failed loading language|error opening data file|didn't load any languages/i.test(err.stderr ?? '')) {
|
|
126
|
+
throw new OcrLanguageMissingError(lang, err.stderr);
|
|
127
|
+
}
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await import('tesseract.js');
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
logger.debug(`ocr engine: tesseract.js unavailable (${err instanceof Error ? err.message : String(err)})`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
logger.debug('ocr engine: tesseract.js (bundled fallback)');
|
|
144
|
+
return {
|
|
145
|
+
name: 'tesseract.js',
|
|
146
|
+
async recognizePng(png, lang) {
|
|
147
|
+
const worker = await jsWorker(lang);
|
|
148
|
+
const { data } = await worker.recognize(png);
|
|
149
|
+
return data.text;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BinaryDependency, ConvertContext, ConvertError, FormatId } from './types.js';
|
|
2
|
+
export declare const GHOSTSCRIPT: BinaryDependency;
|
|
3
|
+
export type OptimizeResult = {
|
|
4
|
+
ok: true;
|
|
5
|
+
input: string;
|
|
6
|
+
output: string;
|
|
7
|
+
format: FormatId;
|
|
8
|
+
bytesIn: number;
|
|
9
|
+
bytesOut: number;
|
|
10
|
+
durationMs: number;
|
|
11
|
+
/** Human-readable summary of what was applied (bitrate, quality...). */
|
|
12
|
+
detail: string;
|
|
13
|
+
warnings: string[];
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
error: ConvertError;
|
|
17
|
+
};
|
|
18
|
+
export interface BinaryResolver {
|
|
19
|
+
(dep: BinaryDependency): Promise<string | null>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Shrink a file toward a target size, fully local.
|
|
23
|
+
* - mp4/mov video: two-pass x264 with a bitrate computed from the target
|
|
24
|
+
* (output is always mp4; the container may change for MOV sources).
|
|
25
|
+
* - jpg/webp: binary-search the largest encoder quality that fits.
|
|
26
|
+
* - png: libimagequant palette search (lossy color reduction, by design).
|
|
27
|
+
* GIF and PDF optimization are not supported yet (pdf planned with V0.3).
|
|
28
|
+
*/
|
|
29
|
+
export declare function optimizeFile(input: string, targetBytes: number, output: string, format: FormatId, resolve: BinaryResolver, ctx: ConvertContext): Promise<OptimizeResult>;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { ExecError, execTool, probeMedia } from './utils/exec.js';
|
|
6
|
+
import { convertError } from './errors.js';
|
|
7
|
+
import { FFMPEG, FFPROBE } from './converters/media.js';
|
|
8
|
+
export const GHOSTSCRIPT = {
|
|
9
|
+
name: 'ghostscript',
|
|
10
|
+
displayName: 'Ghostscript',
|
|
11
|
+
commands: ['gswin64c', 'gswin32c', 'gs'],
|
|
12
|
+
configKey: 'ghostscriptPath',
|
|
13
|
+
extraPaths: {
|
|
14
|
+
// gs installs into a versioned directory and is not always on PATH
|
|
15
|
+
win32: ['C:\\Program Files\\gs\\gs*\\bin\\gswin64c.exe'],
|
|
16
|
+
darwin: ['/opt/homebrew/bin/gs', '/usr/local/bin/gs'],
|
|
17
|
+
linux: ['/usr/bin/gs', '/usr/local/bin/gs'],
|
|
18
|
+
},
|
|
19
|
+
installHint: {
|
|
20
|
+
win32: 'winget install ArtifexSoftware.GhostScript (or download from github.com/ArtifexSoftware/ghostpdl-downloads)',
|
|
21
|
+
darwin: 'brew install ghostscript',
|
|
22
|
+
linux: 'sudo apt install ghostscript',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const FFMPEG_GLOBAL = ['-hide_banner', '-nostdin', '-y'];
|
|
26
|
+
/**
|
|
27
|
+
* Shrink a file toward a target size, fully local.
|
|
28
|
+
* - mp4/mov video: two-pass x264 with a bitrate computed from the target
|
|
29
|
+
* (output is always mp4; the container may change for MOV sources).
|
|
30
|
+
* - jpg/webp: binary-search the largest encoder quality that fits.
|
|
31
|
+
* - png: libimagequant palette search (lossy color reduction, by design).
|
|
32
|
+
* GIF and PDF optimization are not supported yet (pdf planned with V0.3).
|
|
33
|
+
*/
|
|
34
|
+
export async function optimizeFile(input, targetBytes, output, format, resolve, ctx) {
|
|
35
|
+
const started = Date.now();
|
|
36
|
+
try {
|
|
37
|
+
const bytesIn = (await fs.stat(input)).size;
|
|
38
|
+
let bytesOut;
|
|
39
|
+
let detail;
|
|
40
|
+
const warnings = [];
|
|
41
|
+
if (bytesIn <= targetBytes) {
|
|
42
|
+
// Nothing to do: keep the source as the output so the caller always gets a file.
|
|
43
|
+
await fs.copyFile(input, output);
|
|
44
|
+
return {
|
|
45
|
+
ok: true, input, output, format,
|
|
46
|
+
bytesIn, bytesOut: bytesIn,
|
|
47
|
+
durationMs: Date.now() - started,
|
|
48
|
+
detail: `input (${bytesIn} bytes) is already below the target (${targetBytes} bytes); copied unchanged`,
|
|
49
|
+
warnings: [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
switch (format) {
|
|
53
|
+
case 'mp4':
|
|
54
|
+
case 'mov': {
|
|
55
|
+
const applied = await optimizeVideo(input, targetBytes, output, resolve, ctx);
|
|
56
|
+
detail = `two-pass x264: video ${applied.videoKbps}k + audio ${applied.audioKbps}k over ${applied.durationSec.toFixed(1)}s`;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case 'jpg':
|
|
60
|
+
case 'webp': {
|
|
61
|
+
const used = await optimizeQuality(input, targetBytes, output, format);
|
|
62
|
+
detail = `${format} quality ${used}`;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case 'png': {
|
|
66
|
+
const used = await optimizeQuality(input, targetBytes, output, 'png');
|
|
67
|
+
detail = `png palette quality ${used}`;
|
|
68
|
+
warnings.push('PNG palette mode reduces the color count; compare visually.');
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case 'pdf': {
|
|
72
|
+
const applied = await optimizePdf(input, targetBytes, output, resolve, ctx);
|
|
73
|
+
detail = applied.detail;
|
|
74
|
+
warnings.push(...applied.warnings);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case 'gif':
|
|
78
|
+
return notPossible(input, format, 'GIF optimization is not supported yet.');
|
|
79
|
+
default:
|
|
80
|
+
return notPossible(input, format, `optimize_file supports mp4/mov video, jpg/webp/png images and pdf, not ${format}.`);
|
|
81
|
+
}
|
|
82
|
+
bytesOut = (await fs.stat(output)).size;
|
|
83
|
+
return { ok: true, input, output, format, bytesIn, bytesOut, durationMs: Date.now() - started, detail, warnings };
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if (err instanceof OptimizeError)
|
|
87
|
+
return { ok: false, error: err.error };
|
|
88
|
+
if (err instanceof ExecError) {
|
|
89
|
+
const code = err.code === 'timeout' ? 'timeout' : err.code === 'cancelled' ? 'cancelled' : 'conversion_failed';
|
|
90
|
+
return { ok: false, error: convertError(code, `Optimization failed (ffmpeg).`, { detail: err.stderr }) };
|
|
91
|
+
}
|
|
92
|
+
return { ok: false, error: convertError('conversion_failed', `Optimization failed for ${input}`, {
|
|
93
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
94
|
+
}) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
class OptimizeError {
|
|
98
|
+
error;
|
|
99
|
+
constructor(error) {
|
|
100
|
+
this.error = error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function notPossible(input, format, message) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: convertError('unsupported_conversion', message, {
|
|
107
|
+
hint: `Use convert_file for ${format} instead.`,
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async function optimizeVideo(input, targetBytes, output, resolve, ctx) {
|
|
112
|
+
const ffmpeg = await requireBinary(resolve, FFMPEG, ctx);
|
|
113
|
+
const ffprobe = await requireBinary(resolve, FFPROBE, ctx);
|
|
114
|
+
const probe = await probeMedia(ffprobe, input, ctx.timeoutMs, ctx.signal);
|
|
115
|
+
const durationSec = Number.parseFloat(probe?.format?.duration ?? '');
|
|
116
|
+
if (!Number.isFinite(durationSec) || durationSec <= 0) {
|
|
117
|
+
throw new OptimizeError(convertError('invalid_input', 'Cannot determine the video duration (ffprobe returned nothing usable).'));
|
|
118
|
+
}
|
|
119
|
+
// Container overhead + muxing slack: aim at 95% of the target.
|
|
120
|
+
const totalKbits = (targetBytes * 0.95 * 8) / 1000;
|
|
121
|
+
let audioKbps = 128;
|
|
122
|
+
let videoKbps = Math.floor(totalKbits / durationSec) - audioKbps;
|
|
123
|
+
if (videoKbps < 50) {
|
|
124
|
+
audioKbps = 64;
|
|
125
|
+
videoKbps = Math.floor(totalKbits / durationSec) - audioKbps;
|
|
126
|
+
}
|
|
127
|
+
if (videoKbps < 30) {
|
|
128
|
+
const minBytes = Math.ceil(((30 + 64) * 1000 * durationSec) / 8 + 512 * 1024);
|
|
129
|
+
throw new OptimizeError(convertError('unsupported_conversion', `Target size is too small: this ${durationSec.toFixed(1)}s video needs at least about ${(minBytes / 1048576).toFixed(1)} MB.`, { hint: 'Increase target_size_mb, or shorten/trim the video first.' }));
|
|
130
|
+
}
|
|
131
|
+
const passlog = path.join(os.tmpdir(), `dsh-file-convert-pass-${Date.now()}`);
|
|
132
|
+
// pass 2 writes to a scratch file; the output path only ever sees a
|
|
133
|
+
// complete file, so an abort never leaves a broken mp4 behind.
|
|
134
|
+
const scratch = path.join(os.tmpdir(), `dsh-file-convert-out-${Date.now()}.mp4`);
|
|
135
|
+
try {
|
|
136
|
+
try {
|
|
137
|
+
await execTool(ffmpeg, [
|
|
138
|
+
...FFMPEG_GLOBAL, '-i', input,
|
|
139
|
+
'-c:v', 'libx264', '-b:v', `${videoKbps}k`, '-pass', '1', '-passlogfile', passlog,
|
|
140
|
+
'-an', '-f', 'null', '-',
|
|
141
|
+
], { timeoutMs: ctx.timeoutMs, signal: ctx.signal });
|
|
142
|
+
await execTool(ffmpeg, [
|
|
143
|
+
...FFMPEG_GLOBAL, '-i', input,
|
|
144
|
+
'-c:v', 'libx264', '-b:v', `${videoKbps}k`, '-pass', '2', '-passlogfile', passlog,
|
|
145
|
+
'-c:a', 'aac', '-b:a', `${audioKbps}k`, '-movflags', '+faststart',
|
|
146
|
+
scratch,
|
|
147
|
+
], { timeoutMs: ctx.timeoutMs, signal: ctx.signal });
|
|
148
|
+
await fs.copyFile(scratch, output);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// Sources with broken DTS/timestamps (screen recordings, stitched clips)
|
|
152
|
+
// can fail pass 2; point users at the normalize-then-optimize path.
|
|
153
|
+
if (err instanceof ExecError && err.code === 'failed') {
|
|
154
|
+
throw new OptimizeError(convertError('conversion_failed', 'Two-pass encoding failed.', {
|
|
155
|
+
detail: err.stderr,
|
|
156
|
+
hint: 'If the source has unusual timestamps, run convert_file to MP4 first and optimize that result.',
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await fs.rm(scratch, { force: true }).catch(() => undefined);
|
|
164
|
+
for (const suffix of ['-0.log', '-0.log.mbtree']) {
|
|
165
|
+
await fs.rm(passlog + suffix, { force: true }).catch(() => undefined);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { videoKbps, audioKbps, durationSec };
|
|
169
|
+
}
|
|
170
|
+
/** Binary-search the highest encoder quality whose output fits the target. */
|
|
171
|
+
async function optimizeQuality(input, targetBytes, output, format) {
|
|
172
|
+
const encode = async (quality) => {
|
|
173
|
+
const pipeline = sharp(input);
|
|
174
|
+
if (format === 'jpg')
|
|
175
|
+
return pipeline.flatten({ background: '#ffffff' }).jpeg({ quality }).toBuffer();
|
|
176
|
+
if (format === 'webp')
|
|
177
|
+
return pipeline.webp({ quality }).toBuffer();
|
|
178
|
+
return pipeline.png({ palette: true, quality, effort: 7 }).toBuffer();
|
|
179
|
+
};
|
|
180
|
+
const low = format === 'webp' ? 1 : format === 'png' ? 0 : 5;
|
|
181
|
+
const high = format === 'png' ? 100 : 95;
|
|
182
|
+
const floorBuffer = await encode(low);
|
|
183
|
+
if (floorBuffer.length > targetBytes) {
|
|
184
|
+
// Nothing fits: write the smallest variant and let the caller decide.
|
|
185
|
+
await fs.writeFile(output, floorBuffer);
|
|
186
|
+
return low;
|
|
187
|
+
}
|
|
188
|
+
let bestQuality = low;
|
|
189
|
+
let bestBuffer = floorBuffer;
|
|
190
|
+
let lo = low;
|
|
191
|
+
let hi = high;
|
|
192
|
+
while (hi - lo > 1) {
|
|
193
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
194
|
+
const buffer = await encode(mid);
|
|
195
|
+
if (buffer.length <= targetBytes) {
|
|
196
|
+
bestQuality = mid;
|
|
197
|
+
bestBuffer = buffer;
|
|
198
|
+
lo = mid;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
hi = mid;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
await fs.writeFile(output, bestBuffer);
|
|
205
|
+
return bestQuality;
|
|
206
|
+
}
|
|
207
|
+
/** Ghostscript presets, coarse to fine; keep the smallest produced result. */
|
|
208
|
+
const PDF_PRESETS = [
|
|
209
|
+
{ name: 'printer (300 dpi)', setting: '/printer' },
|
|
210
|
+
{ name: 'ebook (150 dpi)', setting: '/ebook' },
|
|
211
|
+
{ name: 'screen (72 dpi)', setting: '/screen' },
|
|
212
|
+
];
|
|
213
|
+
async function optimizePdf(input, targetBytes, output, resolve, ctx) {
|
|
214
|
+
const gs = await requireBinary(resolve, GHOSTSCRIPT, ctx);
|
|
215
|
+
// Default SAFER mode applies: the command-line input file and the explicit
|
|
216
|
+
// -sOutputFile are both inside its allowlist, so no -dNOSAFER is needed.
|
|
217
|
+
const base = [
|
|
218
|
+
'-dNOPAUSE', '-dBATCH', '-dQUIET',
|
|
219
|
+
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.4',
|
|
220
|
+
];
|
|
221
|
+
// Each preset writes to a scratch file; the final winner is copied to the
|
|
222
|
+
// output path atomically, so an abort never leaves a half-written PDF.
|
|
223
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-pdf-opt-'));
|
|
224
|
+
try {
|
|
225
|
+
let smallestSize = Number.POSITIVE_INFINITY;
|
|
226
|
+
let smallestFile = '';
|
|
227
|
+
let smallestName = '';
|
|
228
|
+
for (const [index, preset] of PDF_PRESETS.entries()) {
|
|
229
|
+
if (ctx.signal?.aborted) {
|
|
230
|
+
throw new OptimizeError(convertError('cancelled', 'Optimization cancelled.'));
|
|
231
|
+
}
|
|
232
|
+
const attempt = path.join(tmpDir, `attempt-${index}.pdf`);
|
|
233
|
+
await execTool(gs, [...base, `-sOutputFile=${attempt}`, `-dPDFSETTINGS=${preset.setting}`, input], {
|
|
234
|
+
timeoutMs: ctx.timeoutMs,
|
|
235
|
+
signal: ctx.signal,
|
|
236
|
+
});
|
|
237
|
+
const size = (await fs.stat(attempt)).size;
|
|
238
|
+
if (size <= targetBytes) {
|
|
239
|
+
await fs.copyFile(attempt, output);
|
|
240
|
+
return { detail: `ghostscript ${preset.name}`, warnings: [] };
|
|
241
|
+
}
|
|
242
|
+
if (size < smallestSize) {
|
|
243
|
+
smallestSize = size;
|
|
244
|
+
smallestFile = attempt;
|
|
245
|
+
smallestName = preset.name;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// The screen preset (smallest) is still above the target; keep it with
|
|
249
|
+
// an honest warning.
|
|
250
|
+
await fs.copyFile(smallestFile, output);
|
|
251
|
+
return {
|
|
252
|
+
detail: 'ghostscript screen (72 dpi)',
|
|
253
|
+
warnings: [
|
|
254
|
+
`Even the lowest preset exceeds the target (${Math.round(smallestSize / 1024)} KB produced); the smallest result was kept.`,
|
|
255
|
+
],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async function requireBinary(resolve, dep, ctx) {
|
|
263
|
+
const resolved = await resolve(dep);
|
|
264
|
+
if (!resolved) {
|
|
265
|
+
throw new OptimizeError(convertError('missing_dependency', `Missing dependency: ${dep.displayName ?? dep.name}.`, {
|
|
266
|
+
missing: [dep],
|
|
267
|
+
hint: `Install hint (${process.platform}): ${dep.installHint[platformKey()]}`,
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
void ctx;
|
|
271
|
+
return resolved;
|
|
272
|
+
}
|
|
273
|
+
function platformKey() {
|
|
274
|
+
return process.platform === 'win32' ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux';
|
|
275
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FormatId } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Default output for a single-file conversion: next to the source file, same
|
|
4
|
+
* base name, canonical extension of the target format.
|
|
5
|
+
*/
|
|
6
|
+
export declare function defaultOutputPath(input: string, to: FormatId): string;
|
|
7
|
+
/**
|
|
8
|
+
* Default output for a batch conversion: under outputDir, same base name.
|
|
9
|
+
* outputDir defaults to `<inputDir>/output` and is created by the caller.
|
|
10
|
+
*/
|
|
11
|
+
export declare function batchOutputPath(outputDir: string, input: string, to: FormatId): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { canonicalExtension } from './formats.js';
|
|
3
|
+
/**
|
|
4
|
+
* Default output for a single-file conversion: next to the source file, same
|
|
5
|
+
* base name, canonical extension of the target format.
|
|
6
|
+
*/
|
|
7
|
+
export function defaultOutputPath(input, to) {
|
|
8
|
+
const dir = path.dirname(input);
|
|
9
|
+
const base = path.basename(input, path.extname(input));
|
|
10
|
+
return path.join(dir, base + canonicalExtension(to));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Default output for a batch conversion: under outputDir, same base name.
|
|
14
|
+
* outputDir defaults to `<inputDir>/output` and is created by the caller.
|
|
15
|
+
*/
|
|
16
|
+
export function batchOutputPath(outputDir, input, to) {
|
|
17
|
+
const base = path.basename(input, path.extname(input));
|
|
18
|
+
return path.join(outputDir, base + canonicalExtension(to));
|
|
19
|
+
}
|