feat(cutter): add crash-safe project recovery and export profiles

Persist source-bound cutter projects atomically and expose recovery, discard, save, and open actions through the secured IPC bridge. Add explicit quality, balanced, fast, and archive export pipelines with rotation, audio-track selection, hardware encoder probing, and software fallback. Align main, preload, renderer, UI styles, and focused coverage; remove the reported main-process lint errors without changing unrelated work.
This commit is contained in:
Sucukdeluxe
2026-08-12 02:29:47 +02:00
parent b65d73b236
commit e5114b814f
10 changed files with 955 additions and 39 deletions
+62 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { calculateCutterExportProgress, createCutterExportPlan } from './cutter-export';
import { calculateCutterExportProgress, createCutterExportPlan, parseCutterHardwareEncoders } from './cutter-export';
describe('cutter export segments', () => {
test('sorts playable segments and preserves the caller input', () => {
@@ -113,4 +113,65 @@ describe('cutter export segments', () => {
expect(calculateCutterExportProgress(45, plan)).toBe(100);
expect(calculateCutterExportProgress(-5, plan)).toBe(0);
});
test('builds a rotated quality MP4 profile for an explicitly selected audio stream', () => {
const plan = createCutterExportPlan({
inputFile: 'input.mov',
outputFile: 'output.mp4',
segments: [{ start: 0, end: 20 }],
hasAudio: true,
profile: 'quality',
encoder: 'software',
audioStreamIndex: 1,
rotation: 90,
});
expect(plan.filterComplex).toContain('[0:v]trim=start=0:end=20,setpts=PTS-STARTPTS,transpose=1[v0]');
expect(plan.filterComplex).toContain('[0:a:1]atrim=start=0:end=20,asetpts=PTS-STARTPTS[a0]');
expect(plan.ffmpegArgs).toContain('-noautorotate');
expect(plan.ffmpegArgs).toEqual(expect.arrayContaining(['-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k']));
});
test('falls back to a compatible software encoder when the requested hardware encoder is absent', () => {
const plan = createCutterExportPlan({
inputFile: 'input.mp4',
outputFile: 'output.mp4',
segments: [{ start: 0, end: 20 }],
hasAudio: true,
profile: 'fast',
encoder: 'h264_nvenc',
availableHardwareEncoders: [],
});
expect(plan.selectedEncoder).toBe('libx264');
expect(plan.hardwareFallback).toBe(true);
expect(plan.ffmpegArgs).toEqual(expect.arrayContaining(['-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23']));
});
test('requires the lossless archive profile to use its compatible MKV container', () => {
expect(() => createCutterExportPlan({
inputFile: 'input.mp4',
outputFile: 'archive.mp4',
segments: [{ start: 0, end: 20 }],
hasAudio: true,
profile: 'archive',
encoder: 'software',
})).toThrow('MKV');
const plan = createCutterExportPlan({
inputFile: 'input.mp4',
outputFile: 'archive.mkv',
segments: [{ start: 0, end: 20 }],
hasAudio: true,
profile: 'archive',
encoder: 'software',
});
expect(plan.ffmpegArgs).toEqual(expect.arrayContaining(['-c:v', 'ffv1', '-c:a', 'flac', '-pix_fmt', 'yuv420p']));
expect(plan.ffmpegArgs).not.toContain('+faststart');
});
test('recognizes only offered H.264 hardware encoders from an ffmpeg probe', () => {
expect(parseCutterHardwareEncoders(' V..... h264_nvenc NVIDIA NVENC H.264 encoder\n V..... h264_qsv H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (Intel Quick Sync Video acceleration)\n V..... hevc_amf AMD AMF HEVC encoder')).toEqual(['h264_nvenc', 'h264_qsv']);
});
});
+129 -15
View File
@@ -1,10 +1,33 @@
import * as path from 'node:path';
import type { EditorSegment } from './video-editor';
export type CutterExportProfile = 'quality' | 'balanced' | 'fast' | 'archive';
export type CutterHardwareEncoder = 'h264_nvenc' | 'h264_qsv' | 'h264_amf';
export type CutterExportEncoder = 'software' | CutterHardwareEncoder;
export interface CutterExportProfileDefinition {
id: CutterExportProfile;
label: string;
container: 'mp4' | 'mkv';
}
export const CUTTER_EXPORT_PROFILES: CutterExportProfileDefinition[] = [
{ id: 'quality', label: 'Quality', container: 'mp4' },
{ id: 'balanced', label: 'Balanced', container: 'mp4' },
{ id: 'fast', label: 'Fast', container: 'mp4' },
{ id: 'archive', label: 'Archive', container: 'mkv' },
];
export interface CutterExportPlanOptions {
inputFile: string;
outputFile: string;
segments: readonly EditorSegment[];
hasAudio: boolean;
profile?: CutterExportProfile;
encoder?: CutterExportEncoder;
availableHardwareEncoders?: readonly CutterHardwareEncoder[];
audioStreamIndex?: number;
rotation?: number;
}
export interface CutterExportPlan {
@@ -12,9 +35,13 @@ export interface CutterExportPlan {
remainingDuration: number;
filterComplex: string;
ffmpegArgs: string[];
profile: CutterExportProfile;
selectedEncoder: 'libx264' | 'ffv1' | CutterHardwareEncoder;
hardwareFallback: boolean;
}
const precision = 9;
const hardwareEncoders: CutterHardwareEncoder[] = ['h264_nvenc', 'h264_qsv', 'h264_amf'];
function round(value: number): number {
return Number(value.toFixed(precision));
@@ -53,17 +80,46 @@ function normalizeSegments(segments: readonly EditorSegment[]): EditorSegment[]
return normalized;
}
function createFilterComplex(segments: readonly EditorSegment[], hasAudio: boolean): string {
function getProfileDefinition(profile: CutterExportProfile): CutterExportProfileDefinition {
const definition = CUTTER_EXPORT_PROFILES.find((entry) => entry.id === profile);
if (!definition) throw new Error('Unsupported cutter export profile');
return definition;
}
function normalizeRotation(rotation: number | undefined): 0 | 90 | 180 | 270 {
const normalized = ((rotation ?? 0) % 360 + 360) % 360;
if (normalized === 0 || normalized === 90 || normalized === 180 || normalized === 270) return normalized;
throw new Error('Rotation must be 0, 90, 180, or 270 degrees');
}
function normalizeAudioStreamIndex(value: number | undefined): number {
const index = value ?? 0;
if (!Number.isInteger(index) || index < 0) throw new Error('audioStreamIndex must be a non-negative integer');
return index;
}
function videoRotationFilter(rotation: 0 | 90 | 180 | 270): string | null {
if (rotation === 90) return 'transpose=1';
if (rotation === 180) return 'hflip,vflip';
if (rotation === 270) return 'transpose=2';
return null;
}
function createFilterComplex(segments: readonly EditorSegment[], hasAudio: boolean, audioStreamIndex: number, rotation: 0 | 90 | 180 | 270): string {
const filters: string[] = [];
const concatInputs: string[] = [];
const rotationFilter = videoRotationFilter(rotation);
const audioInput = audioStreamIndex === 0 ? '[0:a]' : `[0:a:${audioStreamIndex}]`;
segments.forEach((segment, index) => {
const start = formatSeconds(segment.start);
const end = formatSeconds(segment.end);
filters.push(`[0:v]trim=start=${start}:end=${end},setpts=PTS-STARTPTS[v${index}]`);
const videoFilters = [`trim=start=${start}:end=${end}`, 'setpts=PTS-STARTPTS'];
if (rotationFilter) videoFilters.push(rotationFilter);
filters.push(`[0:v]${videoFilters.join(',')}[v${index}]`);
concatInputs.push(`[v${index}]`);
if (hasAudio) {
filters.push(`[0:a]atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS[a${index}]`);
filters.push(`${audioInput}atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS[a${index}]`);
concatInputs.push(`[a${index}]`);
}
});
@@ -72,31 +128,89 @@ function createFilterComplex(segments: readonly EditorSegment[], hasAudio: boole
return filters.join(';');
}
function createFfmpegArgs(inputFile: string, outputFile: string, filterComplex: string, hasAudio: boolean): string[] {
const args = [
'-i', inputFile,
'-filter_complex', filterComplex,
'-map', '[outv]',
];
function resolveEncoder(profile: CutterExportProfile, requested: CutterExportEncoder, availableHardwareEncoders: readonly CutterHardwareEncoder[]): { selectedEncoder: CutterExportPlan['selectedEncoder']; hardwareFallback: boolean } {
if (profile === 'archive') {
return { selectedEncoder: 'ffv1', hardwareFallback: requested !== 'software' };
}
if (requested === 'software') {
return { selectedEncoder: 'libx264', hardwareFallback: false };
}
if (hardwareEncoders.includes(requested) && availableHardwareEncoders.includes(requested)) {
return { selectedEncoder: requested, hardwareFallback: false };
}
return { selectedEncoder: 'libx264', hardwareFallback: true };
}
function softwareVideoArgs(profile: CutterExportProfile): string[] {
if (profile === 'quality') return ['-c:v', 'libx264', '-preset', 'slow', '-crf', '18'];
if (profile === 'fast') return ['-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23'];
return ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20'];
}
function hardwareVideoArgs(profile: Exclude<CutterExportProfile, 'archive'>, encoder: CutterHardwareEncoder): string[] {
const quality = profile === 'quality' ? '18' : profile === 'balanced' ? '20' : '23';
if (encoder === 'h264_nvenc') {
return ['-c:v', encoder, '-preset', profile === 'quality' ? 'p6' : profile === 'balanced' ? 'p4' : 'p1', '-cq', quality, '-b:v', '0'];
}
if (encoder === 'h264_qsv') {
return ['-c:v', encoder, '-preset', profile === 'quality' ? 'slow' : profile === 'balanced' ? 'medium' : 'veryfast', '-global_quality', quality];
}
return ['-c:v', encoder, '-quality', profile === 'quality' ? 'quality' : profile === 'balanced' ? 'balanced' : 'speed', '-rc', 'cqp', '-qp_i', quality, '-qp_p', quality];
}
function audioArgs(profile: CutterExportProfile, hasAudio: boolean): string[] {
if (!hasAudio) return ['-an'];
if (profile === 'archive') return ['-c:a', 'flac'];
const bitrate = profile === 'quality' ? '192k' : profile === 'balanced' ? '160k' : '128k';
return ['-c:a', 'aac', '-b:a', bitrate];
}
function createFfmpegArgs(inputFile: string, outputFile: string, filterComplex: string, hasAudio: boolean, profile: CutterExportProfile, selectedEncoder: CutterExportPlan['selectedEncoder'], rotation: 0 | 90 | 180 | 270): string[] {
const args: string[] = [];
if (rotation !== 0) args.push('-noautorotate');
args.push('-i', inputFile, '-filter_complex', filterComplex, '-map', '[outv]');
if (hasAudio) args.push('-map', '[outa]');
args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p');
if (hasAudio) args.push('-c:a', 'aac', '-b:a', '160k');
else args.push('-an');
args.push('-movflags', '+faststart', '-progress', 'pipe:1', '-y', outputFile);
if (selectedEncoder === 'ffv1') args.push('-c:v', 'ffv1', '-level', '3', '-g', '1');
else if (selectedEncoder === 'libx264') args.push(...softwareVideoArgs(profile));
else args.push(...hardwareVideoArgs(profile as Exclude<CutterExportProfile, 'archive'>, selectedEncoder));
args.push('-pix_fmt', 'yuv420p', ...audioArgs(profile, hasAudio));
if (profile !== 'archive') args.push('-movflags', '+faststart');
args.push('-progress', 'pipe:1', '-y', outputFile);
return args;
}
export function parseCutterHardwareEncoders(ffmpegEncodersOutput: string): CutterHardwareEncoder[] {
return hardwareEncoders.filter((encoder) => new RegExp(`\\b${encoder}\\b`, 'i').test(ffmpegEncodersOutput));
}
export function getCutterExportProfile(profile: CutterExportProfile): CutterExportProfileDefinition {
return getProfileDefinition(profile);
}
export function createCutterExportPlan(options: CutterExportPlanOptions): CutterExportPlan {
const inputFile = validatePath(options.inputFile, 'inputFile');
const outputFile = validatePath(options.outputFile, 'outputFile');
const profile = options.profile ?? 'balanced';
const profileDefinition = getProfileDefinition(profile);
const expectedExtension = `.${profileDefinition.container}`;
if (path.extname(outputFile).toLowerCase() !== expectedExtension) {
throw new Error(`${profileDefinition.container.toUpperCase()} output is required for the ${profile} profile`);
}
const segments = normalizeSegments(options.segments);
const audioStreamIndex = normalizeAudioStreamIndex(options.audioStreamIndex);
const rotation = normalizeRotation(options.rotation);
const requestedEncoder = options.encoder ?? 'software';
const encoder = resolveEncoder(profile, requestedEncoder, options.availableHardwareEncoders ?? []);
const remainingDuration = round(segments.reduce((total, segment) => total + segment.end - segment.start, 0));
const filterComplex = createFilterComplex(segments, options.hasAudio);
const filterComplex = createFilterComplex(segments, options.hasAudio, audioStreamIndex, rotation);
return {
segments,
remainingDuration,
filterComplex,
ffmpegArgs: createFfmpegArgs(inputFile, outputFile, filterComplex, options.hasAudio),
ffmpegArgs: createFfmpegArgs(inputFile, outputFile, filterComplex, options.hasAudio, profile, encoder.selectedEncoder, rotation),
profile,
selectedEncoder: encoder.selectedEncoder,
hardwareFallback: encoder.hardwareFallback,
};
}
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { createCutterProjectAutosaveStore } from './cutter-project';
let directory: string;
beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'twitch-vod-manager-cutter-project-'));
});
afterEach(() => {
fs.rmSync(directory, { recursive: true, force: true });
});
describe('cutter project autosave', () => {
test('restores a crash-saved edit after recreating the local store', () => {
const autosavePath = path.join(directory, 'cutter-projects.json');
const source = { path: 'C:\\Media\\source.mp4', size: 2_048_000, mtimeMs: 1_725_000_000_000 };
const savingStore = createCutterProjectAutosaveStore(autosavePath);
savingStore.save({
source,
duration: 120,
fps: 30,
trimStart: 5,
trimEnd: 110,
cuts: [{ id: 'cut-1', start: 32, end: 46 }],
profile: 'quality',
encoder: 'software',
audioStreamIndex: 1,
});
const recovered = createCutterProjectAutosaveStore(autosavePath).find(source);
expect(recovered).toMatchObject({
source,
trimStart: 5,
trimEnd: 110,
cuts: [{ id: 'cut-1', start: 32, end: 46 }],
profile: 'quality',
audioStreamIndex: 1,
});
expect(fs.existsSync(`${autosavePath}.tmp`)).toBe(false);
});
test.each([
['path', (source: { path: string; size: number; mtimeMs: number }) => ({ ...source, path: 'C:\\Media\\renamed.mp4' })],
['size', (source: { path: string; size: number; mtimeMs: number }) => ({ ...source, size: source.size + 1 })],
['modification time', (source: { path: string; size: number; mtimeMs: number }) => ({ ...source, mtimeMs: source.mtimeMs + 1 })],
])('rejects a recovery when the source %s changed', (_field, mutate) => {
const autosavePath = path.join(directory, 'cutter-projects.json');
const savedSource = { path: 'C:\\Media\\source.mp4', size: 2_048_000, mtimeMs: 1_725_000_000_000 };
const changedSource = mutate(savedSource);
const store = createCutterProjectAutosaveStore(autosavePath);
store.save({
source: savedSource,
duration: 120,
fps: 30,
trimStart: 0,
trimEnd: 120,
cuts: [],
profile: 'balanced',
encoder: 'software',
audioStreamIndex: 0,
});
expect(store.find(changedSource)).toBeNull();
});
test('discards the current source recovery', () => {
const autosavePath = path.join(directory, 'cutter-projects.json');
const savedSource = { path: 'C:\\Media\\source.mp4', size: 2_048_000, mtimeMs: 1_725_000_000_000 };
const store = createCutterProjectAutosaveStore(autosavePath);
store.save({
source: savedSource,
duration: 120,
fps: 30,
trimStart: 0,
trimEnd: 120,
cuts: [],
profile: 'balanced',
encoder: 'software',
audioStreamIndex: 0,
});
expect(store.discard(savedSource)).toBe(true);
expect(createCutterProjectAutosaveStore(autosavePath).find(savedSource)).toBeNull();
});
});
+126
View File
@@ -0,0 +1,126 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { writeFileAtomicSync } from '../infra/fs-atomic';
import type { CutterExportEncoder, CutterExportProfile } from './cutter-export';
export interface CutterProjectSource {
path: string;
size: number;
mtimeMs: number;
}
export interface CutterProject {
source: CutterProjectSource;
duration: number;
fps: number;
trimStart: number;
trimEnd: number;
cuts: Array<{ id: string; start: number; end: number }>;
profile: CutterExportProfile;
encoder: CutterExportEncoder;
audioStreamIndex: number;
}
interface CutterProjectDocument {
version: 1;
projects: CutterProject[];
}
function cloneProject(project: CutterProject): CutterProject {
return {
...project,
source: { ...project.source },
cuts: project.cuts.map((cut) => ({ ...cut })),
};
}
function sourceKey(source: CutterProjectSource): string {
return `${source.path}\u0000${source.size}\u0000${source.mtimeMs}`;
}
function isProjectSource(value: unknown): value is CutterProjectSource {
if (!value || typeof value !== 'object') return false;
const source = value as Record<string, unknown>;
return typeof source.path === 'string'
&& source.path.length > 0
&& typeof source.size === 'number'
&& Number.isFinite(source.size)
&& source.size >= 0
&& typeof source.mtimeMs === 'number'
&& Number.isFinite(source.mtimeMs)
&& source.mtimeMs >= 0;
}
function isProject(value: unknown): value is CutterProject {
if (!value || typeof value !== 'object') return false;
const project = value as Record<string, unknown>;
return isProjectSource(project.source)
&& typeof project.duration === 'number'
&& Number.isFinite(project.duration)
&& project.duration > 0
&& typeof project.fps === 'number'
&& Number.isFinite(project.fps)
&& project.fps > 0
&& typeof project.trimStart === 'number'
&& Number.isFinite(project.trimStart)
&& typeof project.trimEnd === 'number'
&& Number.isFinite(project.trimEnd)
&& Array.isArray(project.cuts)
&& project.cuts.every((cut) => cut && typeof cut === 'object'
&& typeof (cut as Record<string, unknown>).id === 'string'
&& typeof (cut as Record<string, unknown>).start === 'number'
&& Number.isFinite((cut as Record<string, unknown>).start)
&& typeof (cut as Record<string, unknown>).end === 'number'
&& Number.isFinite((cut as Record<string, unknown>).end))
&& (project.profile === 'quality' || project.profile === 'balanced' || project.profile === 'fast' || project.profile === 'archive')
&& (project.encoder === 'software' || project.encoder === 'h264_nvenc' || project.encoder === 'h264_qsv' || project.encoder === 'h264_amf')
&& typeof project.audioStreamIndex === 'number'
&& Number.isInteger(project.audioStreamIndex)
&& project.audioStreamIndex >= 0;
}
function readDocument(filePath: string): CutterProjectDocument {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>;
if (parsed.version !== 1 || !Array.isArray(parsed.projects)) return { version: 1, projects: [] };
return { version: 1, projects: parsed.projects.filter(isProject).map(cloneProject) };
} catch {
return { version: 1, projects: [] };
}
}
export interface CutterProjectAutosaveStore {
find(source: CutterProjectSource): CutterProject | null;
save(project: CutterProject): void;
discard(source: CutterProjectSource): boolean;
}
export function createCutterProjectAutosaveStore(filePath: string): CutterProjectAutosaveStore {
const write = (document: CutterProjectDocument): void => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
writeFileAtomicSync(filePath, JSON.stringify(document));
};
return {
find(source) {
const project = readDocument(filePath).projects.find((entry) => sourceKey(entry.source) === sourceKey(source));
return project ? cloneProject(project) : null;
},
save(project) {
if (!isProject(project)) throw new Error('Invalid cutter project');
const document = readDocument(filePath);
const key = sourceKey(project.source);
const projects = document.projects.filter((entry) => sourceKey(entry.source) !== key);
projects.push(cloneProject(project));
write({ version: 1, projects });
},
discard(source) {
const document = readDocument(filePath);
const key = sourceKey(source);
const projects = document.projects.filter((entry) => sourceKey(entry.source) !== key);
if (projects.length === document.projects.length) return false;
write({ version: 1, projects });
return true;
},
};
}