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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user