mediatuna 1.21.11 → 1.22.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/README.md CHANGED
@@ -10,15 +10,12 @@ A home archive is mixed types, interlaced DV, missing tags, and Windows dates th
10
10
 
11
11
  **Docs:** [mediatuna.dev/docs](https://mediatuna.dev/docs) · **Site:** [mediatuna.dev](https://mediatuna.dev)
12
12
 
13
- Your **local agent** can run this for you — dry-run, sample, convert. It should call `mediatuna`, not invent an ffmpeg command line. [Agents](docs/agents.md).
14
-
15
13
  ## Install
16
14
 
17
15
  [Node.js](https://nodejs.org/) 18+ and **ffmpeg** / **ffprobe** on PATH. [Full install](docs/install.md).
18
16
 
19
17
  ```bash
20
- pnpm install
21
- npm link
18
+ npm install -g mediatuna
22
19
  mediatuna --version
23
20
  ```
24
21
 
@@ -36,15 +33,24 @@ More examples: [docs/usage.md](docs/usage.md). Every flag: [docs/options.md](doc
36
33
 
37
34
  ## What you get
38
35
 
36
+ ### Core
37
+
39
38
  - One pass over video and audio (or `--video-only` / `--audio-only`)
40
39
  - Safe defaults — dry-run, duration verify, Recycle Bin, hash before recup cleanup
41
- - Dates survive container tags, filenames, and Windows Created/Modified
42
40
  - Skip normalized MP3s and existing outputs; `--resume` a long batch
43
41
  - AAC copy when the source is already AAC LC (`--reencode-audio` to force 192k)
42
+
43
+ ### Data preservation
44
+
45
+ - Dates survive container tags, filenames, and Windows Created/Modified
44
46
  - Recovery helpers — Everything dupes, PhotoRec folder rebuild
45
47
 
46
48
  Stays 100% local. Flags, logging, dates, and recovery live in the [docs](docs/README.md).
47
49
 
50
+ ## Agents
51
+
52
+ A **local agent** (Cursor, Claude Code, and the like) can dry-run, sample, and convert for you. It should call `mediatuna`, not invent an ffmpeg command line. [Agents](docs/agents.md).
53
+
48
54
  ## Safety
49
55
 
50
56
  Hardening is care, not a guarantee. Encoding is lossy; “same file” is usually size or duration. **Keep a backup you can restore from.** Details: [docs/safety.md](docs/safety.md).
@@ -53,6 +59,8 @@ Hardening is care, not a guarantee. Encoding is lossy; “same file” is usuall
53
59
 
54
60
  ## Development
55
61
 
62
+ Checkout, `pnpm install`, and `npm link` — [full install](docs/install.md).
63
+
56
64
  ```bash
57
65
  pnpm test # unit suite; ffmpeg cases run only if ffmpeg is on PATH
58
66
  pnpm test:ffmpeg # synthetic encode checks
@@ -62,4 +70,4 @@ Core logic is in `lib/`; `index.js` is the CLI. Site (FilePress + docs mount): `
62
70
 
63
71
  ## Roadmap
64
72
 
65
- Shipped work and open items: [specs/improvements.md](specs/improvements.md), [specs/mediatuna.md](specs/mediatuna.md), [specs/partial/hardening.md](specs/partial/hardening.md), [specs/testing.md](specs/testing.md). Parked: [output formats](specs/output-formats.md), [agents](specs/agents.md).
73
+ Shipped work and open items: [specs/improvements.md](specs/improvements.md), [specs/mediatuna.md](specs/mediatuna.md), [specs/partial/hardening.md](specs/partial/hardening.md), [specs/testing.md](specs/testing.md). Parked: [output formats](specs/output-formats.md), [agents](specs/agents.md). Post-publish review: [specs/post-publish-review.md](specs/post-publish-review.md).
package/index.js CHANGED
@@ -107,6 +107,8 @@ Options:
107
107
  --recup-map Map a PhotoRec-style dump to folders using copies found elsewhere
108
108
  --ext <list> With --recup-map: extensions (default: audio + phone video)
109
109
  --apply With --recup-map: copy placed files into proposed-tree/
110
+ --ledger Write provenance into each new MP4/MP3 (comment + mediatuna tag)
111
+ --ledger-json Also append .mediatuna/archive.json under --output (or cwd)
110
112
 
111
113
  Video formats: AVI, MOV, MOD, VOB, MTS, M2TS, MPG, MPEG, WMV, 3GP, 3G2 → MP4
112
114
  Audio formats: MP3, FLAC, WAV, AIFF, M4A, AAC, OGG, Opus, WMA, AC3, DTS, AMR, QCP → MP3
@@ -175,6 +177,7 @@ const {
175
177
  deleteOriginals, cleanupOriginals, audioQuality, extractAudio, resume, jobs: requestedJobs,
176
178
  stampDates, stampVideo, backupDir, dupeReport, dupeHash, recupMap, recupExt, recupApply,
177
179
  yes, deletePermanent, include, exclude, archiveDir, sampleSeconds, reencodeAudio,
180
+ ledger, ledgerJsonPath,
178
181
  } = cli;
179
182
 
180
183
  const LOG_FILE = cli.logFile;
@@ -554,6 +557,7 @@ const modeParts = buildModeParts({
554
557
  nvenc, quality, deinterlace, mediaMode, preferMtime, embedArt, extractAudio, audioQuality,
555
558
  verify, deleteOriginals, deletePermanent, dryRun, resume, jobs,
556
559
  archiveDir, sampleSeconds, reencodeAudio,
560
+ ledger, ledgerJsonPath,
557
561
  });
558
562
  logConsole(`MediaTuna: ${files.length} files | ${modeParts.join(' | ')}`);
559
563
  const sourceDir = resolved.mode === 'folder' ? resolved.targetPath : path.dirname(files[0]);
@@ -562,6 +566,10 @@ logConsole(formatWriteLocationBanner({
562
566
  outputDir,
563
567
  sourceDir,
564
568
  }));
569
+ if (ledger) {
570
+ logFile('Ledger: embed provenance in each new MP4/MP3 (comment + mediatuna tag)');
571
+ if (ledgerJsonPath) logFile(`Ledger json: ${ledgerJsonPath}`);
572
+ }
565
573
 
566
574
  let preflight = await buildPreflightEntries(files, outputDir, force, mediaMode, {
567
575
  audioQuality,
@@ -687,6 +695,9 @@ const { stats, failedPaths, convertedInputs } = await runConversion({
687
695
  config: {
688
696
  dryRun, verify, keepPartial, quality, audioQuality, deinterlace, nvenc,
689
697
  preferMtime, embedArt, extractAudio, mediaMode, verbose, jobs, sampleSeconds, reencodeAudio,
698
+ ledger, ledgerJsonPath,
699
+ sourceRoot: resolved.mode === 'folder' ? resolved.targetPath : path.dirname(files[0]),
700
+ mediatunaVersion: pkg.version,
690
701
  },
691
702
  logger,
692
703
  progress: {
package/lib/cli-config.js CHANGED
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { VALID_DEINTERLACE, VALID_QUALITY } from './constants.js';
4
4
  import { parseJobsValue } from './jobs.js';
5
5
  import { parseGlobList } from './globs.js';
6
+ import { defaultLedgerJsonPath } from './ledger.js';
6
7
 
7
8
  export class CliConfigError extends Error {
8
9
  constructor(message) {
@@ -53,6 +54,8 @@ export const CLI_PARSE_OPTIONS = {
53
54
  archive: { type: 'string' },
54
55
  sample: { type: 'string' },
55
56
  'reencode-audio': { type: 'boolean' },
57
+ ledger: { type: 'boolean' },
58
+ 'ledger-json': { type: 'boolean' },
56
59
  };
57
60
 
58
61
  export function buildCliConfig(values, positionals, { cwd = process.cwd(), homedir = os.homedir() } = {}) {
@@ -280,6 +283,36 @@ export function buildCliConfig(values, positionals, { cwd = process.cwd(), homed
280
283
  throw new CliConfigError('--sample cannot be used with --recup-map.');
281
284
  }
282
285
 
286
+ const ledgerJson = values['ledger-json'] ?? false;
287
+ const ledger = (values.ledger ?? false) || ledgerJson;
288
+
289
+ if (ledger && values['stamp-dates']) {
290
+ throw new CliConfigError('--ledger cannot be used with --stamp-dates.');
291
+ }
292
+
293
+ if (ledger && values['dupe-report']) {
294
+ throw new CliConfigError('--ledger cannot be used with --dupe-report.');
295
+ }
296
+
297
+ if (ledger && values['recup-map']) {
298
+ throw new CliConfigError('--ledger cannot be used with --recup-map.');
299
+ }
300
+
301
+ if (ledger && sampleSeconds) {
302
+ throw new CliConfigError('--ledger cannot be used with --sample (samples are not the archive).');
303
+ }
304
+
305
+ if (ledger && values['cleanup-originals']) {
306
+ throw new CliConfigError('--ledger writes provenance on new outputs; omit --cleanup-originals.');
307
+ }
308
+
309
+ const ledgerJsonPath = ledgerJson
310
+ ? defaultLedgerJsonPath({
311
+ outputDir: values.output ? path.resolve(values.output) : null,
312
+ cwd,
313
+ })
314
+ : null;
315
+
283
316
  let jobs;
284
317
  try {
285
318
  jobs = parseJobsValue(values.jobs);
@@ -331,6 +364,8 @@ export function buildCliConfig(values, positionals, { cwd = process.cwd(), homed
331
364
  archiveDir: values.archive ? path.resolve(values.archive) : null,
332
365
  sampleSeconds,
333
366
  reencodeAudio: values['reencode-audio'] ?? false,
367
+ ledger,
368
+ ledgerJsonPath,
334
369
  target,
335
370
  };
336
371
  }
package/lib/ledger.js ADDED
@@ -0,0 +1,169 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { execFileSync } from 'child_process';
4
+ import { parseFilenameDate } from './filename-dates.js';
5
+ import { parseCreationDate } from './stamp-dates.js';
6
+
7
+ export const LEDGER_JSON_NAME = 'archive.json';
8
+
9
+ let sidecarChain = Promise.resolve();
10
+
11
+ export function defaultLedgerJsonPath({ outputDir = null, cwd = process.cwd() } = {}) {
12
+ return path.join(outputDir || cwd, '.mediatuna', LEDGER_JSON_NAME);
13
+ }
14
+
15
+ export function relativeLedgerPath(root, abs) {
16
+ const resolvedRoot = path.resolve(root);
17
+ const resolvedAbs = path.resolve(abs);
18
+ const rel = path.relative(resolvedRoot, resolvedAbs);
19
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
20
+ return resolvedAbs.split(path.sep).join('/');
21
+ }
22
+ return rel.split(path.sep).join('/');
23
+ }
24
+
25
+ export function isoFromParsed(parsed) {
26
+ if (!parsed) return null;
27
+ const date = `${parsed.year}-${parsed.month}-${parsed.day}`;
28
+ if (!parsed.hasTime) return date;
29
+ return `${date}T${parsed.hour}:${parsed.minute}:${parsed.second}`;
30
+ }
31
+
32
+ function fromFilesystemTime(value) {
33
+ if (value == null || value === 'N/A') return null;
34
+ const parsed = parseCreationDate(value);
35
+ if (!parsed) return null;
36
+ return { sourceDate: isoFromParsed(parsed), dateSource: 'filesystem' };
37
+ }
38
+
39
+ function fromFilename(input) {
40
+ const hit = parseFilenameDate(path.basename(input));
41
+ if (!hit?.parsed) return null;
42
+ return {
43
+ sourceDate: isoFromParsed(hit.parsed),
44
+ dateSource: hit.source === 'mtime' ? 'filesystem' : 'filename',
45
+ };
46
+ }
47
+
48
+ export function resolveLedgerDate(meta, input, { preferMtime = false } = {}) {
49
+ const fromTag = parseCreationDate(meta?.creation_time);
50
+ if (fromTag) {
51
+ return { sourceDate: isoFromParsed(fromTag), dateSource: 'embedded' };
52
+ }
53
+
54
+ const filenameHit = fromFilename(input);
55
+ const fsHit = fromFilesystemTime(meta?.modified_time);
56
+
57
+ if (preferMtime) return fsHit || filenameHit || { sourceDate: null, dateSource: null };
58
+ return filenameHit || fsHit || { sourceDate: null, dateSource: null };
59
+ }
60
+
61
+ export function ledgerAction(mode) {
62
+ return mode === 'extract' ? 'extract' : 'transcode';
63
+ }
64
+
65
+ export function buildLedgerRecord({
66
+ input,
67
+ out,
68
+ meta,
69
+ mode,
70
+ sourceRoot,
71
+ mediatunaVersion,
72
+ preferMtime = false,
73
+ verified = false,
74
+ durationDeltaMs = null,
75
+ processedAt = new Date().toISOString(),
76
+ }) {
77
+ const { sourceDate, dateSource } = resolveLedgerDate(meta, input, { preferMtime });
78
+ const ext = path.extname(input).slice(1).toLowerCase() || 'unknown';
79
+ return {
80
+ source: relativeLedgerPath(sourceRoot, input),
81
+ sourceFormat: ext,
82
+ sourceDate,
83
+ dateSource,
84
+ action: ledgerAction(mode),
85
+ output: relativeLedgerPath(sourceRoot, out),
86
+ verified,
87
+ durationDeltaMs,
88
+ processedAt,
89
+ mediatunaVersion,
90
+ };
91
+ }
92
+
93
+ export function formatLedgerComment(record) {
94
+ const date = record.sourceDate ? ` ${record.sourceDate}` : '';
95
+ const dest = path.posix.basename(String(record.output || '').replaceAll('\\', '/'));
96
+ return `MediaTuna ${record.mediatunaVersion} ${record.action} ${record.source} → ${dest}${date}`.slice(0, 250);
97
+ }
98
+
99
+ export function buildLedgerRemuxArgs(src, dest, record, { video }) {
100
+ const comment = formatLedgerComment(record);
101
+ const tag = JSON.stringify(record);
102
+ const args = [
103
+ '-hide_banner', '-loglevel', 'error', '-y',
104
+ '-i', src,
105
+ '-map', '0',
106
+ '-c', 'copy',
107
+ '-map_metadata', '0',
108
+ ];
109
+ if (video) {
110
+ args.push('-movflags', '+faststart+use_metadata_tags');
111
+ } else {
112
+ args.push('-id3v2_version', '3', '-write_id3v1', '0');
113
+ }
114
+ args.push('-metadata', `comment=${comment}`, '-metadata', `mediatuna=${tag}`, dest);
115
+ return args;
116
+ }
117
+
118
+ export function applyLedgerMetadata(outPath, record, { ffmpeg = 'ffmpeg' } = {}) {
119
+ const ext = path.extname(outPath);
120
+ const tmp = `${outPath}.ledger${ext}`;
121
+ const args = buildLedgerRemuxArgs(outPath, tmp, record, {
122
+ video: ext.toLowerCase() === '.mp4',
123
+ });
124
+ try {
125
+ execFileSync(ffmpeg, args, { stdio: 'pipe', timeout: 120_000 });
126
+ if (!fs.existsSync(tmp)) {
127
+ throw new Error('ledger remux produced no file');
128
+ }
129
+ if (fs.existsSync(outPath)) fs.unlinkSync(outPath);
130
+ fs.renameSync(tmp, outPath);
131
+ } catch (err) {
132
+ try {
133
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
134
+ } catch { /* keep the original output */ }
135
+ throw err;
136
+ }
137
+ }
138
+
139
+ export function upsertLedgerSidecar(filePath, record) {
140
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
141
+ let doc = { version: 1, updatedAt: record.processedAt, entries: [] };
142
+ if (fs.existsSync(filePath)) {
143
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
144
+ if (parsed && typeof parsed === 'object') {
145
+ doc = parsed;
146
+ }
147
+ if (!Array.isArray(doc.entries)) doc.entries = [];
148
+ if (doc.version == null) doc.version = 1;
149
+ }
150
+ const index = doc.entries.findIndex((entry) => entry.output === record.output);
151
+ if (index >= 0) doc.entries[index] = record;
152
+ else doc.entries.push(record);
153
+ doc.updatedAt = record.processedAt;
154
+ fs.writeFileSync(filePath, `${JSON.stringify(doc, null, 2)}\n`);
155
+ return doc;
156
+ }
157
+
158
+ export function enqueueSidecarWrite(filePath, record) {
159
+ const next = sidecarChain.then(
160
+ () => upsertLedgerSidecar(filePath, record),
161
+ () => upsertLedgerSidecar(filePath, record),
162
+ );
163
+ sidecarChain = next.catch(() => {});
164
+ return next;
165
+ }
166
+
167
+ export function resetSidecarQueue() {
168
+ sidecarChain = Promise.resolve();
169
+ }
@@ -73,7 +73,7 @@ export function buildModeParts({
73
73
  mediaMode, preferMtime, embedArt, extractAudio, audioQuality, verify,
74
74
  deleteOriginals, deletePermanent = false, dryRun, resume, jobs = 1, dupeReport = false, dupeHash = false,
75
75
  recupMap = false, recupApply = false, archiveDir = null, sampleSeconds = null,
76
- reencodeAudio = false,
76
+ reencodeAudio = false, ledger = false, ledgerJsonPath = null,
77
77
  }) {
78
78
  if (recupMap) {
79
79
  const parts = ['recup-map'];
@@ -118,6 +118,8 @@ export function buildModeParts({
118
118
  if (archiveDir) modeParts.push('archive');
119
119
  if (sampleSeconds) modeParts.push(`sample:${sampleSeconds}s`);
120
120
  if (reencodeAudio) modeParts.push('reencode-audio');
121
+ if (ledger) modeParts.push('ledger');
122
+ if (ledgerJsonPath) modeParts.push('ledger-json');
121
123
  if (resume) modeParts.push('resume');
122
124
  if (jobs > 1) modeParts.push(`jobs:${jobs}`);
123
125
  if (dryRun) modeParts.push('dry-run');
package/lib/run.js CHANGED
@@ -21,6 +21,11 @@ import { formatDryRunProgress, shellQuote } from './format.js';
21
21
  import { secondsToHMS, formatHMSValue, formatTimeHMS, timeToSeconds } from './time.js';
22
22
  import { verifyOutput } from './verify.js';
23
23
  import { runWithConcurrency } from './jobs.js';
24
+ import {
25
+ applyLedgerMetadata,
26
+ buildLedgerRecord,
27
+ enqueueSidecarWrite,
28
+ } from './ledger.js';
24
29
 
25
30
  export async function runConversion({
26
31
  preflight,
@@ -33,6 +38,7 @@ export async function runConversion({
33
38
  dryRun, verify, keepPartial, quality, audioQuality, deinterlace, nvenc,
34
39
  preferMtime, embedArt, extractAudio, mediaMode, verbose, jobs = 1,
35
40
  sampleSeconds = null, reencodeAudio = false,
41
+ ledger = false, ledgerJsonPath = null, sourceRoot = null, mediatunaVersion = null,
36
42
  } = config;
37
43
 
38
44
  const stats = { done: 0, skipped: 0, failed: 0, resumed: 0 };
@@ -158,26 +164,61 @@ export async function runConversion({
158
164
  ? `${Math.floor(elapsedSec / 60)}m ${Math.round(elapsedSec % 60)}s`
159
165
  : `${elapsedSec.toFixed(1)}s`;
160
166
 
167
+ let check = null;
161
168
  if (verify) {
162
169
  const verifyContext = isAudioJob ? { sourceMeta: meta, sourceSize: meta.size } : null;
163
- const check = verifyOutput(out, expectedDuration, expectedType, verifyContext);
170
+ check = verifyOutput(out, expectedDuration, expectedType, verifyContext);
164
171
  if (!check.ok) throw new Error(`verification failed: ${check.reason}`);
165
172
  for (const warning of check.warnings ?? []) {
166
173
  logger.logFile(`[WARN] ${passName}: ${warning}`);
167
174
  if (verbose) logger.logConsole(`[WARN] ${passName}: ${warning}`);
168
175
  }
176
+ }
177
+
178
+ if (ledger) {
179
+ const durationDeltaMs = check?.ok && expectedDuration > 0
180
+ ? Math.round((check.duration - expectedDuration) * 1000)
181
+ : null;
182
+ const record = buildLedgerRecord({
183
+ input,
184
+ out,
185
+ meta,
186
+ mode,
187
+ sourceRoot: sourceRoot || path.dirname(input),
188
+ mediatunaVersion,
189
+ preferMtime,
190
+ verified: Boolean(check?.ok),
191
+ durationDeltaMs,
192
+ });
169
193
  try {
170
- applyOutputTimestamps(input, out);
171
- } catch { }
172
- const outBase = path.basename(out);
194
+ applyLedgerMetadata(out, record);
195
+ logger.logFile(`Ledger: wrote metadata on ${path.basename(out)}`);
196
+ } catch (err) {
197
+ logger.logFile(`[WARN] ledger metadata failed for ${path.basename(out)}: ${err.message}`);
198
+ if (verbose) {
199
+ logger.logConsole(`[WARN] ledger metadata failed for ${path.basename(out)}: ${err.message}`);
200
+ }
201
+ }
202
+ if (ledgerJsonPath) {
203
+ try {
204
+ await enqueueSidecarWrite(ledgerJsonPath, record);
205
+ } catch (err) {
206
+ logger.logFile(`[WARN] ledger json failed: ${err.message}`);
207
+ if (verbose) logger.logConsole(`[WARN] ledger json failed: ${err.message}`);
208
+ }
209
+ }
210
+ }
211
+
212
+ try {
213
+ applyOutputTimestamps(input, out);
214
+ } catch { }
215
+
216
+ const outBase = path.basename(out);
217
+ if (verify) {
173
218
  const detail = `✓ ${outBase} | Verified (${secondsToHMS(check.duration)}) | Metadata copied`;
174
219
  logger.logFile(`${detail} | --- end ${passName} (${elapsedStr}) ---`);
175
220
  logger.logToConsole(verbose ? detail : `✓ ${outBase}`);
176
221
  } else {
177
- try {
178
- applyOutputTimestamps(input, out);
179
- } catch { }
180
- const outBase = path.basename(out);
181
222
  logger.logFile(`✓ ${outBase} | --- end ${passName} (${elapsedStr}) ---`);
182
223
  logger.logToConsole(verbose ? `✓ ${outBase}` : `✓ ${outBase}`);
183
224
  }
@@ -235,6 +276,10 @@ export async function runConversion({
235
276
  stats.resumed++;
236
277
  convertedInputs.push(input);
237
278
  }
279
+ if (ledger && (videoStatus.startsWith('convert') || extractStatus?.startsWith('convert'))) {
280
+ logger.logFile(`Would write ledger metadata for ${base}`);
281
+ if (ledgerJsonPath) logger.logFile(`Would append ledger json: ${ledgerJsonPath}`);
282
+ }
238
283
  if (total > 1) {
239
284
  logger.logConsole(formatDryRunProgress(index, total, entry.status, base));
240
285
  } else if (verbose && (videoStatus.startsWith('convert') || extractStatus?.startsWith('convert'))) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mediatuna",
3
- "version": "1.21.11",
3
+ "version": "1.22.0",
4
4
  "type": "module",
5
5
  "description": "Make sure the old files can still play. Local convert to MP4 and MP3, dates kept.",
6
6
  "main": "index.js",
@@ -33,7 +33,8 @@
33
33
  "homepage": "https://mediatuna.dev",
34
34
  "files": [
35
35
  "index.js",
36
- "lib"
36
+ "lib",
37
+ "skills"
37
38
  ],
38
39
  "scripts": {
39
40
  "start": "node index.js",
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: mediatuna
3
+ description: >-
4
+ Runs the MediaTuna local CLI to batch-convert home video/audio archives to
5
+ MP4 and MP3 (ffmpeg, dates, skip-already-done). Use when the user mentions
6
+ MediaTuna, old camcorder files, PhotoRec dumps, or converting a folder
7
+ of AVI/MOV/MOD/VOB/FLAC/WMA to playable files.
8
+ ---
9
+
10
+ # MediaTuna
11
+
12
+ Local batch convert. Stays 100% local. The CLI is the only encoder — do not replace it with a hand-rolled ffmpeg line.
13
+
14
+ ## Before any write
15
+
16
+ 1. `mediatuna --version` (or `node index.js --version` from a checkout). If missing: Node 18+, ffmpeg/ffprobe on PATH, then `npm install -g mediatuna`. Docs: https://mediatuna.dev/docs/install/
17
+ 2. Confirm the user has a **restore backup** of the source folder. If they do not, stop and say so.
18
+ 3. `--dry-run` first. Read the preflight table with them.
19
+
20
+ ```bash
21
+ mediatuna "./archives/media" --dry-run
22
+ ```
23
+
24
+ 4. Then a short sample to a **different** folder:
25
+
26
+ ```bash
27
+ mediatuna "./archives/media" --sample 20 --output "./samples"
28
+ ```
29
+
30
+ 5. Full convert to `--output`, not in-place, until they have checked a sample:
31
+
32
+ ```bash
33
+ mediatuna "./archives/media" --output "./converted"
34
+ ```
35
+
36
+ ## Do not
37
+
38
+ - `--delete-originals`, `--cleanup-originals`, or `--archive` unless the user explicitly asked **and** the backup exists.
39
+ - `--force` on a first run.
40
+ - Upload source media, samples, or logs that contain home paths to a remote host beyond what this agent session already does.
41
+ - Promise lossless. Encodes are lossy. “Same file” is size or duration, not a bitstream compare.
42
+
43
+ ## Useful flags
44
+
45
+ | Need | Flag |
46
+ |------|------|
47
+ | Video or audio only | `--video-only` / `--audio-only` |
48
+ | Nested folders | `--recursive` |
49
+ | Resume overnight | `--resume` |
50
+ | Skip junk paths | `--include "*.avi"` / `--exclude "previews/**"` |
51
+ | NVENC parallel | `--jobs 3` (after a single-job sample works) |
52
+ | Forced AAC 192k on video | `--reencode-audio` (default copies AAC LC) |
53
+ | PhotoRec tree | `--recup-map` — add `--hash` before `--apply` or cleanup |
54
+ | Dupes | `--dupe-report` (Everything / `es.exe` on Windows) |
55
+ | Provenance on new files | `--ledger` (embed). Add `--ledger-json` for `.mediatuna/archive.json` |
56
+
57
+ Full list: https://mediatuna.dev/docs/options/ · safety: https://mediatuna.dev/docs/safety/
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ npm install -g mediatuna
63
+ mediatuna --version
64
+ ```
65
+
66
+ Copy this skill from the global package if you are not in a checkout:
67
+
68
+ ```bash
69
+ mkdir -p ~/.cursor/skills/mediatuna
70
+ cp "$(npm root -g)/mediatuna/skills/mediatuna/SKILL.md" ~/.cursor/skills/mediatuna/SKILL.md
71
+ ```
72
+
73
+ ### Development checkout
74
+
75
+ ```bash
76
+ pnpm install
77
+ npm link
78
+ mediatuna --version
79
+ ```
80
+
81
+ `pnpm link -g` errors on some setups. After `git pull`, run `npm link` again.