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.
Files changed (67) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +225 -0
  3. package/README.zh-CN.md +203 -0
  4. package/cordis.patch.yml +3 -0
  5. package/lib/config.d.ts +36 -0
  6. package/lib/config.js +17 -0
  7. package/lib/core/binaries/cache.d.ts +13 -0
  8. package/lib/core/binaries/cache.js +137 -0
  9. package/lib/core/binaries/download.d.ts +21 -0
  10. package/lib/core/binaries/download.js +140 -0
  11. package/lib/core/binary.d.ts +7 -0
  12. package/lib/core/binary.js +29 -0
  13. package/lib/core/converters/data.d.ts +14 -0
  14. package/lib/core/converters/data.js +145 -0
  15. package/lib/core/converters/image.d.ts +12 -0
  16. package/lib/core/converters/image.js +64 -0
  17. package/lib/core/converters/media.d.ts +21 -0
  18. package/lib/core/converters/media.js +135 -0
  19. package/lib/core/converters/office.d.ts +33 -0
  20. package/lib/core/converters/office.js +206 -0
  21. package/lib/core/converters/pdf-env.d.ts +1 -0
  22. package/lib/core/converters/pdf-env.js +8 -0
  23. package/lib/core/converters/pdf.d.ts +22 -0
  24. package/lib/core/converters/pdf.js +316 -0
  25. package/lib/core/detect.d.ts +16 -0
  26. package/lib/core/detect.js +121 -0
  27. package/lib/core/errors.d.ts +5 -0
  28. package/lib/core/errors.js +16 -0
  29. package/lib/core/formats.d.ts +11 -0
  30. package/lib/core/formats.js +60 -0
  31. package/lib/core/index.d.ts +25 -0
  32. package/lib/core/index.js +52 -0
  33. package/lib/core/inspect.d.ts +13 -0
  34. package/lib/core/inspect.js +134 -0
  35. package/lib/core/ocr.d.ts +38 -0
  36. package/lib/core/ocr.js +152 -0
  37. package/lib/core/optimizers.d.ts +29 -0
  38. package/lib/core/optimizers.js +275 -0
  39. package/lib/core/paths.d.ts +11 -0
  40. package/lib/core/paths.js +19 -0
  41. package/lib/core/router.d.ts +64 -0
  42. package/lib/core/router.js +288 -0
  43. package/lib/core/types.d.ts +199 -0
  44. package/lib/core/types.js +8 -0
  45. package/lib/core/utils/exec.d.ts +38 -0
  46. package/lib/core/utils/exec.js +89 -0
  47. package/lib/core/utils/pages.d.ts +12 -0
  48. package/lib/core/utils/pages.js +46 -0
  49. package/lib/format.d.ts +23 -0
  50. package/lib/format.js +84 -0
  51. package/lib/index.d.ts +6 -0
  52. package/lib/index.js +51 -0
  53. package/lib/tools/batch-convert.d.ts +5 -0
  54. package/lib/tools/batch-convert.js +155 -0
  55. package/lib/tools/convert-file.d.ts +3 -0
  56. package/lib/tools/convert-file.js +57 -0
  57. package/lib/tools/inspect-file.d.ts +3 -0
  58. package/lib/tools/inspect-file.js +29 -0
  59. package/lib/tools/install-media.d.ts +9 -0
  60. package/lib/tools/install-media.js +55 -0
  61. package/lib/tools/install-ocr.d.ts +7 -0
  62. package/lib/tools/install-ocr.js +43 -0
  63. package/lib/tools/list-conversions.d.ts +2 -0
  64. package/lib/tools/list-conversions.js +18 -0
  65. package/lib/tools/optimize-file.d.ts +3 -0
  66. package/lib/tools/optimize-file.js +91 -0
  67. package/package.json +66 -0
@@ -0,0 +1,64 @@
1
+ import { type DetectOutcome } from './detect.js';
2
+ import type { ConvertResult, Converter, ConversionStatus, FormatId, InspectResult, Logger } from './types.js';
3
+ export interface RouterDefaults {
4
+ /** Default JPEG/WebP quality (1-100). */
5
+ quality: number;
6
+ /** Default rasterization DPI for pdf/svg inputs. */
7
+ dpi: number;
8
+ /** Cooperative deadline for one conversion, in milliseconds. */
9
+ timeoutMs: number;
10
+ /**
11
+ * When non-empty, EXPLICIT output paths must resolve inside one of these
12
+ * directories (case-insensitive on Windows). Default output (next to the
13
+ * input) is exempt. Empty = unrestricted, which is fine for a personal
14
+ * single-user harness; set it for shared deployments.
15
+ */
16
+ outputRoots?: string[];
17
+ /** Config-key overrides for external binary resolution, e.g. ffmpegPath. */
18
+ binaryOverrides?: Record<string, string>;
19
+ /** Inputs larger than this are refused (bytes). Default 2 GiB. */
20
+ maxInputBytes?: number;
21
+ /** Full-document PDF rasterization refuses to exceed this page count. Default 200. */
22
+ maxPdfPages?: number;
23
+ /** Rasterized pixels per page are clamped to this. Default 16 MP. */
24
+ maxOutputPixels?: number;
25
+ }
26
+ export interface ConvertFileRequest {
27
+ input: string;
28
+ /** Free-text target format; aliases like 'jpeg' / '.yml' are accepted. */
29
+ outputFormat: string;
30
+ /** Absolute output path; defaults to next to the input file. */
31
+ output?: string;
32
+ overwrite?: boolean;
33
+ quality?: number;
34
+ dpi?: number;
35
+ /** One-based inclusive page selection for PDF inputs, e.g. '1-3,5'. */
36
+ pages?: string;
37
+ /** OCR the pages instead of reading the text layer (PDF → TXT). */
38
+ ocr?: boolean;
39
+ /** OCR languages, '+'-separated. Default 'chi_sim+eng'. */
40
+ ocrLang?: string;
41
+ }
42
+ export interface ConvertRunContext {
43
+ logger: Logger;
44
+ signal?: AbortSignal;
45
+ }
46
+ /**
47
+ * The facade every tool talks to. Owns the capability registry, detection,
48
+ * dependency checks, overwrite policy, and error normalization — converters
49
+ * only see well-formed requests.
50
+ */
51
+ export declare class ConversionRouter {
52
+ private readonly defaults;
53
+ private readonly converters;
54
+ private readonly byPair;
55
+ constructor(defaults?: RouterDefaults);
56
+ register(converter: Converter): void;
57
+ detect(input: string): Promise<DetectOutcome>;
58
+ route(from: FormatId, to: FormatId): Converter | null;
59
+ /** The full matrix with dependency availability, for list_conversions. */
60
+ listConversions(): Promise<ConversionStatus[]>;
61
+ /** Full pipeline: detect → route → deps → overwrite policy → convert. */
62
+ convertFile(req: ConvertFileRequest, run: ConvertRunContext): Promise<ConvertResult>;
63
+ inspect(input: string): Promise<InspectResult>;
64
+ }
@@ -0,0 +1,288 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { convertError, toConvertError } from './errors.js';
4
+ import { formatCategory, FORMAT_IDS, parseFormatArg } from './formats.js';
5
+ import { detectFile, DetectError } from './detect.js';
6
+ import { inspectFile } from './inspect.js';
7
+ import { defaultOutputPath } from './paths.js';
8
+ import { resolveBinary } from './binary.js';
9
+ import { FFPROBE } from './converters/media.js';
10
+ const DEFAULTS = { quality: 85, dpi: 150, timeoutMs: 120_000 };
11
+ /**
12
+ * The facade every tool talks to. Owns the capability registry, detection,
13
+ * dependency checks, overwrite policy, and error normalization — converters
14
+ * only see well-formed requests.
15
+ */
16
+ export class ConversionRouter {
17
+ defaults;
18
+ converters = new Map();
19
+ byPair = new Map();
20
+ constructor(defaults = DEFAULTS) {
21
+ this.defaults = defaults;
22
+ }
23
+ register(converter) {
24
+ if (this.converters.has(converter.id)) {
25
+ throw new Error(`Converter id already registered: ${converter.id}`);
26
+ }
27
+ this.converters.set(converter.id, converter);
28
+ for (const cap of converter.capabilities) {
29
+ let targets = this.byPair.get(cap.from);
30
+ if (!targets)
31
+ this.byPair.set(cap.from, (targets = new Map()));
32
+ if (targets.has(cap.to)) {
33
+ throw new Error(`Duplicate capability ${cap.from} -> ${cap.to} (${converter.id})`);
34
+ }
35
+ targets.set(cap.to, converter);
36
+ }
37
+ }
38
+ detect(input) {
39
+ return detectFile(input);
40
+ }
41
+ route(from, to) {
42
+ return this.byPair.get(from)?.get(to) ?? null;
43
+ }
44
+ /** The full matrix with dependency availability, for list_conversions. */
45
+ async listConversions() {
46
+ const statuses = [];
47
+ for (const converter of this.converters.values()) {
48
+ for (const cap of converter.capabilities) {
49
+ const missing = [];
50
+ for (const dep of converter.binaryDeps) {
51
+ if (!(await resolveBinary(dep, this.defaults.binaryOverrides ?? {}, NULL_LOGGER)))
52
+ missing.push(dep.name);
53
+ }
54
+ for (const name of cap.extraDeps ?? [])
55
+ missing.push(name);
56
+ statuses.push({
57
+ from: cap.from,
58
+ to: cap.to,
59
+ available: missing.length === 0,
60
+ experimental: cap.experimental ?? false,
61
+ missing,
62
+ });
63
+ }
64
+ }
65
+ statuses.sort((a, b) => (CATEGORY_RANK.get(a.from) ?? 0) - (CATEGORY_RANK.get(b.from) ?? 0) ||
66
+ a.from.localeCompare(b.from) ||
67
+ a.to.localeCompare(b.to));
68
+ return statuses;
69
+ }
70
+ /** Full pipeline: detect → route → deps → overwrite policy → convert. */
71
+ async convertFile(req, run) {
72
+ const to = parseFormatArg(req.outputFormat);
73
+ if (!to) {
74
+ const failure = convertError('unsupported_conversion', `Unknown output format '${req.outputFormat}'.`, { hint: `Supported formats: ${FORMAT_IDS.join(', ')}.` });
75
+ return { ok: false, input: req.input, error: failure };
76
+ }
77
+ try {
78
+ const inputStat = await fs.stat(req.input).catch(() => null);
79
+ if (inputStat?.isDirectory()) {
80
+ return {
81
+ ok: false, input: req.input, to,
82
+ error: convertError('invalid_input', `Input is a directory, not a file: ${req.input}`),
83
+ };
84
+ }
85
+ const maxInputBytes = this.defaults.maxInputBytes ?? 2 * 1024 ** 3;
86
+ if (inputStat && inputStat.size > maxInputBytes) {
87
+ const formatMb = (n) => `${Math.round(n / 1048576).toLocaleString('en-US')} MB`;
88
+ return {
89
+ ok: false, input: req.input, to,
90
+ error: convertError('invalid_input', `Input is ${formatMb(inputStat.size)}, above the ${formatMb(maxInputBytes)} limit.`, {
91
+ hint: "Raise 'maxInputMb' in the plugin config, or convert in parts.",
92
+ }),
93
+ };
94
+ }
95
+ const { detection, warnings } = await detectFile(req.input);
96
+ const from = detection.format;
97
+ if (from === to) {
98
+ return {
99
+ ok: false, input: req.input, from, to,
100
+ error: convertError('unsupported_conversion', `Input is already ${to}.`),
101
+ };
102
+ }
103
+ const converter = this.route(from, to);
104
+ if (!converter) {
105
+ return {
106
+ ok: false, input: req.input, from, to,
107
+ error: convertError('unsupported_conversion', `No conversion from ${from} to ${to}.`, {
108
+ hint: 'Run list_conversions to see the supported matrix.',
109
+ }),
110
+ };
111
+ }
112
+ const missing = await missingDeps(converter, this.defaults.binaryOverrides ?? {});
113
+ if (missing.length > 0) {
114
+ return {
115
+ ok: false, input: req.input, from, to,
116
+ error: convertError('missing_dependency', `Missing external dependency: ${missing.map((m) => m.name).join(', ')}.`, {
117
+ missing,
118
+ hint: `Install hint (${process.platform}): ${missing.map((m) => platformHint(m.installHint)).join('; ')}`,
119
+ }),
120
+ };
121
+ }
122
+ const output = req.output ?? defaultOutputPath(req.input, to);
123
+ if (await isSameFile(output, req.input)) {
124
+ return {
125
+ ok: false, input: req.input, from, to,
126
+ error: convertError('invalid_input', 'Output path equals the input path; converting would destroy the source.', {
127
+ hint: 'Choose a different output name or omit output to write next to the input with the new extension.',
128
+ }),
129
+ };
130
+ }
131
+ if (req.output !== undefined && !(await isInsideRoots(output, this.defaults.outputRoots))) {
132
+ return {
133
+ ok: false, input: req.input, from, to,
134
+ error: convertError('invalid_input', `Output path ${output} is outside every configured outputRoot.`, {
135
+ hint: `Allowed roots: ${this.defaults.outputRoots?.join(', ')}. Omit output to write next to the input.`,
136
+ }),
137
+ };
138
+ }
139
+ await fs.mkdir(path.dirname(output), { recursive: true });
140
+ const overwrite = req.overwrite ?? false;
141
+ if (!overwrite && (await exists(output))) {
142
+ return {
143
+ ok: false, input: req.input, from, to,
144
+ error: convertError('output_exists', `Output file already exists: ${output}`, {
145
+ hint: 'Pass overwrite: true to replace it.',
146
+ }),
147
+ };
148
+ }
149
+ const options = {
150
+ overwrite,
151
+ // quality has one shared default; dpi semantics differ per backend
152
+ // (PDF rasterization vs SVG density), so converters apply their own.
153
+ quality: req.quality ?? this.defaults.quality,
154
+ dpi: req.dpi,
155
+ pages: req.pages,
156
+ ocr: req.ocr,
157
+ ocrLang: req.ocrLang,
158
+ };
159
+ const request = { input: req.input, output, from, to, options };
160
+ const ctx = {
161
+ logger: run.logger,
162
+ signal: run.signal,
163
+ timeoutMs: this.defaults.timeoutMs,
164
+ limits: { maxPdfPages: this.defaults.maxPdfPages, maxOutputPixels: this.defaults.maxOutputPixels },
165
+ };
166
+ const result = await withTimeout(converter.convert(request, ctx), ctx.timeoutMs, {
167
+ input: req.input, from, to,
168
+ });
169
+ if (result.ok)
170
+ result.warnings.unshift(...warnings);
171
+ return result;
172
+ }
173
+ catch (err) {
174
+ if (err instanceof DetectError) {
175
+ return { ok: false, input: req.input, to, error: err.error };
176
+ }
177
+ return {
178
+ ok: false,
179
+ input: req.input,
180
+ to,
181
+ error: toConvertError(err, `Conversion failed for ${req.input}`),
182
+ };
183
+ }
184
+ }
185
+ async inspect(input) {
186
+ const { detection } = await detectFile(input);
187
+ const bytes = (await fs.stat(input)).size;
188
+ let media;
189
+ if (detection.format === 'mp4' || detection.format === 'mov' || detection.format === 'mp3' || detection.format === 'wav') {
190
+ const ffprobe = await resolveBinary(FFPROBE, this.defaults.binaryOverrides ?? {}, NULL_LOGGER);
191
+ if (ffprobe) {
192
+ media = { ffprobePath: ffprobe, timeoutMs: Math.min(this.defaults.timeoutMs, 30_000) };
193
+ }
194
+ }
195
+ return inspectFile(input, detection, bytes, media);
196
+ }
197
+ }
198
+ /** Category ordering is static; compute the rank once instead of per sort call. */
199
+ const CATEGORY_RANK = new Map([...FORMAT_IDS]
200
+ .sort((a, b) => formatCategory(a).localeCompare(formatCategory(b)))
201
+ .map((format, index) => [format, index]));
202
+ /**
203
+ * realpath the deepest EXISTING ancestor of a path and rejoin the remainder:
204
+ * symlinks anywhere in the existing part are resolved, which is what
205
+ * outputRoots confinement needs (a symlink inside a root can point outside).
206
+ */
207
+ async function realPathBestEffort(p) {
208
+ let current = path.resolve(p);
209
+ const tail = [];
210
+ for (;;) {
211
+ try {
212
+ return path.join(await fs.realpath(current), ...tail.reverse());
213
+ }
214
+ catch {
215
+ /* segment does not exist yet - walk up */
216
+ }
217
+ const parent = path.dirname(current);
218
+ if (parent === current)
219
+ return path.resolve(p);
220
+ tail.push(path.basename(current));
221
+ current = parent;
222
+ }
223
+ }
224
+ async function isSameFile(a, b) {
225
+ const ra = await realPathBestEffort(a);
226
+ const rb = await realPathBestEffort(b);
227
+ if (ra === rb)
228
+ return true;
229
+ // Windows paths are case-insensitive; also fold / vs \.
230
+ return process.platform === 'win32' && ra.replace(/\\/g, '/').toLowerCase() === rb.replace(/\\/g, '/').toLowerCase();
231
+ }
232
+ async function isInsideRoots(output, roots) {
233
+ if (!roots || roots.length === 0)
234
+ return true;
235
+ const candidate = await realPathBestEffort(output);
236
+ const normalized = process.platform === 'win32' ? candidate.replace(/\\/g, '/').toLowerCase() : candidate;
237
+ for (const root of roots) {
238
+ const prefix = await realPathBestEffort(path.resolve(root));
239
+ const normalizedPrefix = process.platform === 'win32' ? prefix.replace(/\\/g, '/').toLowerCase() : prefix;
240
+ if (normalized === normalizedPrefix ||
241
+ normalized.startsWith(normalizedPrefix.endsWith('/') ? normalizedPrefix : normalizedPrefix + '/')) {
242
+ return true;
243
+ }
244
+ }
245
+ return false;
246
+ }
247
+ async function missingDeps(converter, overrides) {
248
+ const missing = [];
249
+ for (const dep of converter.binaryDeps) {
250
+ if (!(await resolveBinary(dep, overrides, NULL_LOGGER)))
251
+ missing.push(dep);
252
+ }
253
+ return missing;
254
+ }
255
+ function platformHint(hint) {
256
+ return process.platform === 'win32' ? hint.win32 : process.platform === 'darwin' ? hint.darwin : hint.linux;
257
+ }
258
+ async function exists(path) {
259
+ try {
260
+ await fs.access(path);
261
+ return true;
262
+ }
263
+ catch {
264
+ return false;
265
+ }
266
+ }
267
+ /** Cooperative deadline: long-running converters also observe ctx.signal. */
268
+ async function withTimeout(promise, timeoutMs, meta) {
269
+ let timer;
270
+ const timeout = new Promise((resolve) => {
271
+ timer = setTimeout(() => {
272
+ resolve({
273
+ ok: false,
274
+ input: meta.input,
275
+ from: meta.from,
276
+ to: meta.to,
277
+ error: convertError('timeout', `Conversion exceeded ${Math.round(timeoutMs / 1000)}s and was abandoned.`),
278
+ });
279
+ }, timeoutMs);
280
+ });
281
+ try {
282
+ return await Promise.race([promise, timeout]);
283
+ }
284
+ finally {
285
+ clearTimeout(timer);
286
+ }
287
+ }
288
+ const NULL_LOGGER = { debug() { }, info() { }, warn() { }, error() { } };
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Core type definitions for dsh-file-convert.
3
+ *
4
+ * This module (and everything under src/core) is deliberately independent of
5
+ * DeepSeek Harness / Cordis so it can be tested and reused without a running
6
+ * harness. The DSH glue layer lives in src/index.ts and src/tools/.
7
+ */
8
+ export type FormatId = 'pdf' | 'docx' | 'pptx' | 'xlsx' | 'png' | 'jpg' | 'webp' | 'svg' | 'gif' | 'mp4' | 'mov' | 'mp3' | 'wav' | 'json' | 'yaml' | 'csv' | 'txt';
9
+ export type FormatCategory = 'document' | 'image' | 'video' | 'audio' | 'data' | 'text';
10
+ export interface FormatMeta {
11
+ category: FormatCategory;
12
+ /** Canonical extension first; used for default output naming. */
13
+ extensions: string[];
14
+ mime: string;
15
+ }
16
+ /** One declarative row of the conversion matrix. */
17
+ export interface ConversionCapability {
18
+ from: FormatId;
19
+ to: FormatId;
20
+ /** Binaries needed by this specific row beyond the converter-level deps. */
21
+ extraDeps?: string[];
22
+ experimental?: boolean;
23
+ }
24
+ /**
25
+ * An external binary the plugin shells out to.
26
+ * Resolution order: config override → PATH → known install locations →
27
+ * plugin cache. `probe` allows deep checks (python with a specific module
28
+ * installed), `extraPaths` covers Windows installs that are not on PATH.
29
+ */
30
+ export interface BinaryDependency {
31
+ name: string;
32
+ /** Name shown to users when this dependency is missing. */
33
+ displayName?: string;
34
+ /** Command names probed on PATH, e.g. ['ffmpeg']. */
35
+ commands: string[];
36
+ /** Plugin config key that overrides the resolved path, e.g. 'ffmpegPath'. */
37
+ configKey?: string;
38
+ /** Absolute locations probed when the command is not on PATH. */
39
+ extraPaths?: {
40
+ win32?: string[];
41
+ darwin?: string[];
42
+ linux?: string[];
43
+ };
44
+ /**
45
+ * Deep check run against the resolved path; false counts as missing
46
+ * (e.g. python present but the required package not importable).
47
+ * Results are memoized per (dependency, path) for the process lifetime.
48
+ */
49
+ probe?: (resolvedPath: string) => Promise<boolean>;
50
+ installHint: {
51
+ win32: string;
52
+ darwin: string;
53
+ linux: string;
54
+ };
55
+ }
56
+ export interface ConvertOptions {
57
+ overwrite: boolean;
58
+ /** 1-100, lossy targets (jpg/webp) only. */
59
+ quality?: number;
60
+ /** CSS color used to flatten alpha for non-alpha targets. Default '#ffffff'. */
61
+ background?: string;
62
+ /** Rasterization density for vector inputs (pdf/svg), in DPI. */
63
+ dpi?: number;
64
+ /** Indentation for json/yaml output. Default 2. */
65
+ indent?: number;
66
+ /** CSV delimiter. Default: sniffed from the first lines (, ; \t). */
67
+ delimiter?: string;
68
+ /** One-based inclusive page selection for PDF inputs, e.g. '1-3,5,8-10'. */
69
+ pages?: string;
70
+ /** OCR pages instead of reading the text layer (PDF → TXT). */
71
+ ocr?: boolean;
72
+ /** OCR languages, '+'-separated. Default 'chi_sim+eng'. */
73
+ ocrLang?: string;
74
+ /** Escape hatch for backend-specific options. */
75
+ extra?: Record<string, unknown>;
76
+ }
77
+ export interface ConvertRequest {
78
+ /** Absolute path to an existing file. */
79
+ input: string;
80
+ /** Absolute output path, already resolved by the caller. */
81
+ output: string;
82
+ from: FormatId;
83
+ to: FormatId;
84
+ options: ConvertOptions;
85
+ }
86
+ export type ConvertErrorCode = 'input_not_found' | 'unknown_format' | 'unsupported_conversion' | 'missing_dependency' | 'invalid_input' | 'output_exists' | 'conversion_failed' | 'timeout' | 'cancelled';
87
+ export interface ConvertError {
88
+ code: ConvertErrorCode;
89
+ /** One human-readable line; agents relay this to the user verbatim. */
90
+ message: string;
91
+ /** Truncated stderr / decoder output for debugging. */
92
+ detail?: string;
93
+ missing?: BinaryDependency[];
94
+ hint?: string;
95
+ }
96
+ export type ConvertResult = {
97
+ ok: true;
98
+ input: string;
99
+ output: string;
100
+ from: FormatId;
101
+ to: FormatId;
102
+ bytesIn: number;
103
+ bytesOut: number;
104
+ durationMs: number;
105
+ warnings: string[];
106
+ /**
107
+ * Present when one input produced several outputs (multi-page PDF
108
+ * rasterization). Always includes `output` as the first entry.
109
+ */
110
+ outputs?: string[];
111
+ } | {
112
+ ok: false;
113
+ input: string;
114
+ /** Absent when the input format could not be detected. */
115
+ from?: FormatId;
116
+ /** Absent when the requested output format was not parseable. */
117
+ to?: FormatId;
118
+ error: ConvertError;
119
+ };
120
+ export interface Logger {
121
+ debug(msg: string): void;
122
+ info(msg: string): void;
123
+ warn(msg: string): void;
124
+ error(msg: string): void;
125
+ }
126
+ export interface ConvertContext {
127
+ logger: Logger;
128
+ signal?: AbortSignal;
129
+ /** Hard deadline for a single conversion, in milliseconds. */
130
+ timeoutMs: number;
131
+ /** Resource ceilings applied to rasterization and page loops. */
132
+ limits?: {
133
+ /** Full-document PDF rasterization refuses to exceed this page count. */
134
+ maxPdfPages?: number;
135
+ /** Rasterized pixels per page (width × height) are clamped to this. */
136
+ maxOutputPixels?: number;
137
+ };
138
+ }
139
+ export interface Converter {
140
+ id: string;
141
+ capabilities: ConversionCapability[];
142
+ binaryDeps: BinaryDependency[];
143
+ /** Max parallel conversions for batch pools. 1 = must serialize. */
144
+ concurrency: number;
145
+ convert(req: ConvertRequest, ctx: ConvertContext): Promise<ConvertResult>;
146
+ }
147
+ export interface Detection {
148
+ format: FormatId;
149
+ /**
150
+ * 'magic' = decided by file content (binary magic, SVG/JSON sniffing);
151
+ * 'guess' = weak content heuristic (YAML document marker);
152
+ * 'extension' = decided by file name.
153
+ */
154
+ confidence: 'magic' | 'extension' | 'guess';
155
+ mime?: string;
156
+ }
157
+ export interface ConversionStatus {
158
+ from: FormatId;
159
+ to: FormatId;
160
+ available: boolean;
161
+ experimental: boolean;
162
+ /** Names of missing external binaries; empty when available. */
163
+ missing: string[];
164
+ }
165
+ export type InspectResult = {
166
+ kind: 'image';
167
+ format: FormatId;
168
+ width: number;
169
+ height: number;
170
+ channels?: number;
171
+ bytes: number;
172
+ } | {
173
+ kind: 'pdf';
174
+ pages: number;
175
+ encrypted: boolean;
176
+ /** Heuristic: almost no extractable text → likely a scanned PDF. */
177
+ likelyScanned: boolean;
178
+ bytes: number;
179
+ } | {
180
+ kind: 'media';
181
+ format: FormatId;
182
+ durationSec?: number;
183
+ width?: number;
184
+ height?: number;
185
+ fps?: number;
186
+ audioCodec?: string;
187
+ /** Present when ffprobe is missing; inspect degrades instead of failing. */
188
+ probeUnavailable?: boolean;
189
+ bytes: number;
190
+ } | {
191
+ kind: 'data';
192
+ format: FormatId;
193
+ records?: number;
194
+ bytes: number;
195
+ } | {
196
+ kind: 'unknown';
197
+ bytes: number;
198
+ mime?: string;
199
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Core type definitions for dsh-file-convert.
3
+ *
4
+ * This module (and everything under src/core) is deliberately independent of
5
+ * DeepSeek Harness / Cordis so it can be tested and reused without a running
6
+ * harness. The DSH glue layer lives in src/index.ts and src/tools/.
7
+ */
8
+ export {};
@@ -0,0 +1,38 @@
1
+ export interface ExecOptions {
2
+ /** Hard deadline in milliseconds; the child is killed when it fires. */
3
+ timeoutMs: number;
4
+ /** Cooperative cancellation from the harness (agent abort). */
5
+ signal?: AbortSignal;
6
+ /** Max stderr bytes kept for diagnostics. */
7
+ maxStderrBytes?: number;
8
+ }
9
+ export interface ExecOutcome {
10
+ code: number;
11
+ stdout: string;
12
+ stderr: string;
13
+ }
14
+ export declare class ExecError extends Error {
15
+ readonly code: 'timeout' | 'cancelled' | 'failed';
16
+ readonly stderr?: string | undefined;
17
+ constructor(code: 'timeout' | 'cancelled' | 'failed', message: string, stderr?: string | undefined);
18
+ }
19
+ /**
20
+ * Run an external tool, collecting stderr. Hardened for ffmpeg-style long
21
+ * jobs: no shell (array args), timeout kill, cooperative abort, capped
22
+ * stderr, hidden console window on Windows.
23
+ */
24
+ export declare function execTool(command: string, args: string[], opts: ExecOptions): Promise<ExecOutcome>;
25
+ /** ffprobe wrapper: one JSON document about the file's format and streams. */
26
+ export declare function probeMedia(ffprobePath: string, input: string, timeoutMs: number, signal?: AbortSignal): Promise<MediaProbe | null>;
27
+ export interface MediaProbe {
28
+ format?: {
29
+ duration?: string;
30
+ };
31
+ streams?: Array<{
32
+ codec_type?: string;
33
+ codec_name?: string;
34
+ width?: number;
35
+ height?: number;
36
+ r_frame_rate?: string;
37
+ }>;
38
+ }
@@ -0,0 +1,89 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { truncate } from '../errors.js';
3
+ export class ExecError extends Error {
4
+ code;
5
+ stderr;
6
+ constructor(code, message, stderr) {
7
+ super(message);
8
+ this.code = code;
9
+ this.stderr = stderr;
10
+ }
11
+ }
12
+ /**
13
+ * Run an external tool, collecting stderr. Hardened for ffmpeg-style long
14
+ * jobs: no shell (array args), timeout kill, cooperative abort, capped
15
+ * stderr, hidden console window on Windows.
16
+ */
17
+ export function execTool(command, args, opts) {
18
+ return new Promise((resolve, reject) => {
19
+ const child = spawn(command, args, {
20
+ windowsHide: true,
21
+ stdio: ['ignore', 'pipe', 'pipe'],
22
+ });
23
+ let settled = false;
24
+ const stderrCap = opts.maxStderrBytes ?? 64 * 1024;
25
+ let stderrBytes = 0;
26
+ const stderrChunks = [];
27
+ const stdoutChunks = [];
28
+ let timer;
29
+ const onAbort = () => {
30
+ if (!settled)
31
+ child.kill('SIGKILL');
32
+ };
33
+ const finish = (fn) => {
34
+ if (settled)
35
+ return;
36
+ settled = true;
37
+ clearTimeout(timer);
38
+ opts.signal?.removeEventListener('abort', onAbort);
39
+ fn();
40
+ };
41
+ if (opts.signal) {
42
+ if (opts.signal.aborted) {
43
+ child.kill('SIGKILL');
44
+ return finish(() => reject(new ExecError('cancelled', 'Conversion cancelled.')));
45
+ }
46
+ opts.signal.addEventListener('abort', onAbort);
47
+ }
48
+ timer = setTimeout(() => {
49
+ child.kill('SIGKILL');
50
+ finish(() => reject(new ExecError('timeout', `External tool timed out after ${Math.round(opts.timeoutMs / 1000)}s: ${command}`, Buffer.concat(stderrChunks).toString('utf8'))));
51
+ }, opts.timeoutMs);
52
+ child.stderr.on('data', (chunk) => {
53
+ if (stderrBytes >= stderrCap)
54
+ return;
55
+ stderrChunks.push(chunk);
56
+ stderrBytes += chunk.length;
57
+ });
58
+ child.stdout.on('data', (chunk) => {
59
+ stdoutChunks.push(chunk);
60
+ });
61
+ child.on('error', (err) => {
62
+ finish(() => reject(new ExecError('failed', `Failed to run ${command}: ${err.message}`)));
63
+ });
64
+ child.on('close', (code, signal) => {
65
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
66
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
67
+ if (opts.signal?.aborted) {
68
+ return finish(() => reject(new ExecError('cancelled', 'Conversion cancelled.', stderr)));
69
+ }
70
+ if (signal) {
71
+ return finish(() => reject(new ExecError('timeout', `External tool was killed (${signal}): ${command}`, truncate(stderr))));
72
+ }
73
+ if (code !== 0) {
74
+ return finish(() => reject(new ExecError('failed', `${command} exited with code ${code}`, truncate(stderr))));
75
+ }
76
+ finish(() => resolve({ code: code ?? 0, stdout, stderr }));
77
+ });
78
+ });
79
+ }
80
+ /** ffprobe wrapper: one JSON document about the file's format and streams. */
81
+ export async function probeMedia(ffprobePath, input, timeoutMs, signal) {
82
+ try {
83
+ const { stdout } = await execTool(ffprobePath, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', input], { timeoutMs, signal, maxStderrBytes: 8 * 1024 });
84
+ return JSON.parse(stdout);
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }