Gesamtauswahl
diff --git a/src/main.ts b/src/main.ts
index d8bfae3..081290a 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -21,7 +21,7 @@ import { watchRendererChanges } from './main/dev-reload';
import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output';
import { PartialDownloadRegistry } from './main/domain/partial-download';
import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './main/queue/process-registry';
-import type { DbHandle } from './main/infra/db';
+import { openDatabase, type DbHandle } from './main/infra/db';
import {
normalizeLogin,
normalizeAutoRecordPollSeconds,
@@ -40,7 +40,17 @@ import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, Do
import { buildVodPreviewFrameUrls } from './main/domain/vod-preview';
import { getWindowsAppIdentity } from './main/domain/app-identity';
import { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange, type EditorCut } from './main/domain/video-editor';
-import { calculateCutterExportProgress, createCutterExportPlan } from './main/domain/cutter-export';
+import {
+ calculateCutterExportProgress,
+ createCutterExportPlan,
+ CUTTER_EXPORT_PROFILES,
+ getCutterExportProfile,
+ parseCutterHardwareEncoders,
+ type CutterExportEncoder,
+ type CutterExportProfile,
+ type CutterHardwareEncoder,
+} from './main/domain/cutter-export';
+import { createCutterProjectAutosaveStore, type CutterProject, type CutterProjectSource } from './main/domain/cutter-project';
import {
CUTTER_SESSION_CAPABILITY_TTL_MS,
FileCapabilityStore,
@@ -56,6 +66,7 @@ import { createExportableConfig } from './main/domain/config-export';
import { commitQueueMutation, persistStateChange } from './main/domain/persistence-commit';
import { resolveSecretInputUpdate } from './main/domain/secret-input';
import { createSecretStore, type SecretStore } from './main/domain/secret-store';
+import { migrateJsonToSqlite } from './main/domain/migrator';
import { createElectronSecureStorage } from './main/infra/secure-storage';
import { readChatFile } from './main/domain/chat-reader';
import {
@@ -85,6 +96,7 @@ const GITHUB_RELEASES_DOWNLOAD_BASE_URL = 'https://github.com/Sucukdeluxe/Twitch
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log');
const PARTIAL_DOWNLOADS_FILE = path.join(APPDATA_DIR, 'partial-downloads.json');
+const CUTTER_PROJECT_AUTOSAVE_FILE = path.join(APPDATA_DIR, 'cutter-projects.json');
const TOOLS_DIR = path.join(APPDATA_DIR, 'tools');
const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink');
const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg');
@@ -141,6 +153,7 @@ if (!fs.existsSync(APPDATA_DIR)) {
fs.mkdirSync(APPDATA_DIR, { recursive: true });
}
const partialDownloadRegistry = new PartialDownloadRegistry(PARTIAL_DOWNLOADS_FILE);
+const cutterProjectAutosaves = createCutterProjectAutosaveStore(CUTTER_PROJECT_AUTOSAVE_FILE);
// ==========================================
// INTERFACES
@@ -274,6 +287,8 @@ interface VideoInfo {
audioCodec: string | null;
previewCompatible: boolean;
variableFrameRate: boolean;
+ rotation: 0 | 90 | 180 | 270;
+ audioStreams: Array<{ index: number; codec: string; channels: number; language: string | null }>;
}
interface VideoEditorMedia {
@@ -312,6 +327,9 @@ interface VideoEditExportRequest {
trimStart: number;
trimEnd: number;
cuts: EditorCut[];
+ profile: CutterExportProfile;
+ encoder: CutterExportEncoder;
+ audioStreamIndex: number;
}
interface RendererVideoEditExportRequest {
@@ -320,6 +338,9 @@ interface RendererVideoEditExportRequest {
trimStart: number;
trimEnd: number;
cuts: EditorCut[];
+ profile?: CutterExportProfile;
+ encoder?: CutterExportEncoder;
+ audioStreamIndex?: number;
}
interface ReleaseUpdateInfo {
@@ -735,6 +756,7 @@ let currentCutterProcess: ChildProcess | null = null;
let currentCutterPartialFile: string | null = null;
let cutterExportActive = false;
let cutterExportCancelled = false;
+let cutterHardwareEncoderProbe: Promise | null = null;
let cutterPreparedInput: { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null = null;
let cutterMediaGeneration = 0;
let cutterMediaRequestGeneration = 0;
@@ -2629,7 +2651,7 @@ async function getVodStoryboard(vodId: string): Promise {
return null;
}
- let manifest: StoryboardManifestEntry[] | null = null;
+ let manifest: StoryboardManifestEntry[];
try {
const manifestResp = await axios.get(manifestUrl, {
timeout: 6000,
@@ -2771,6 +2793,12 @@ function isSupportedVideoEditorInput(filePath: string): boolean {
return ['.mp4', '.m4v', '.mov', '.webm', '.mkv', '.ts', '.avi'].includes(path.extname(filePath).toLowerCase());
}
+function normalizeCutterRotation(value: unknown): 0 | 90 | 180 | 270 {
+ const numeric = Number(value);
+ const normalized = ((numeric % 360) + 360) % 360;
+ return normalized === 90 || normalized === 180 || normalized === 270 ? normalized : 0;
+}
+
async function getVideoInfo(filePath: string, trackedProcesses?: Set, timeoutMs = 30000): Promise {
const ffmpegReady = await ensureFfmpegInstalled();
if (!ffmpegReady) {
@@ -2826,7 +2854,15 @@ async function getVideoInfo(filePath: string, trackedProcesses?: Set s.codec_type === 'video');
- const audioStream = info.streams?.find((s: any) => s.codec_type === 'audio');
+ const audioStreams = (Array.isArray(info.streams) ? info.streams : [])
+ .filter((stream: any) => stream?.codec_type === 'audio')
+ .map((stream: any, index: number) => ({
+ index,
+ codec: String(stream.codec_name || '').toLowerCase(),
+ channels: Number.isFinite(stream.channels) ? Number(stream.channels) : 0,
+ language: typeof stream.tags?.language === 'string' ? stream.tags.language : null,
+ }));
+ const audioStream = audioStreams[0] ?? null;
const duration = parseFloat(info.format?.duration || videoStream?.duration || '0');
const averageFps = parseFrameRate(videoStream?.avg_frame_rate);
const realFps = parseFrameRate(videoStream?.r_frame_rate);
@@ -2838,8 +2874,11 @@ async function getVideoInfo(filePath: string, trackedProcesses?: Set 0 && realFps > 0 && Math.abs(averageFps - realFps) / Math.max(averageFps, realFps) > 0.005;
+ const rotationData = Array.isArray(videoStream.side_data_list)
+ ? videoStream.side_data_list.find((entry: any) => Number.isFinite(Number(entry?.rotation)))?.rotation
+ : undefined;
finish({
duration,
width: videoStream.width,
@@ -2850,6 +2889,8 @@ async function getVideoInfo(filePath: string, trackedProcesses?: Set {
+ if (cutterHardwareEncoderProbe) return await cutterHardwareEncoderProbe;
+ cutterHardwareEncoderProbe = (async (): Promise => {
+ if (!await ensureFfmpegInstalled()) return [];
+ return await new Promise((resolve) => {
+ const proc = spawn(getFFmpegPath(), ['-hide_banner', '-encoders'], { windowsHide: true });
+ currentCutterProbeProcesses.add(proc);
+ proc.stderr?.resume();
+ let output = '';
+ let settled = false;
+ const finish = (encoders: CutterHardwareEncoder[]): void => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ currentCutterProbeProcesses.delete(proc);
+ resolve(encoders);
+ };
+ const timeout = setTimeout(() => {
+ try { proc.kill(); } catch { }
+ finish([]);
+ }, 8000);
+ proc.stdout?.on('data', (chunk) => { output += chunk.toString(); });
+ proc.on('close', (code) => finish(code === 0 ? parseCutterHardwareEncoders(output) : []));
+ proc.on('error', () => finish([]));
+ });
+ })();
+ try {
+ return await cutterHardwareEncoderProbe;
+ } catch {
+ cutterHardwareEncoderProbe = null;
+ return [];
+ }
+}
+
function cancelCutterMediaPreparation(): void {
cutterAssetRunGeneration += 1;
for (const process of currentCutterMediaProcesses) {
@@ -3100,6 +3175,66 @@ function getCutterInputIdentity(filePath: string): { path: string; size: number;
}
}
+function getCutterProjectSource(filePath: string): CutterProjectSource | null {
+ const identity = getCutterInputIdentity(filePath);
+ return identity ? { path: identity.path, size: identity.size, mtimeMs: identity.mtimeMs } : null;
+}
+
+function isCutterExportProfile(value: unknown): value is CutterExportProfile {
+ return value === 'quality' || value === 'balanced' || value === 'fast' || value === 'archive';
+}
+
+function isCutterExportEncoder(value: unknown): value is CutterExportEncoder {
+ return value === 'software' || value === 'h264_nvenc' || value === 'h264_qsv' || value === 'h264_amf';
+}
+
+function createCutterProject(filePath: string, info: VideoInfo, value: unknown): CutterProject | null {
+ if (!value || typeof value !== 'object') return null;
+ const project = value as Record;
+ const profile = project.profile;
+ const encoder = project.encoder;
+ const audioStreamIndex = project.audioStreamIndex;
+ const trimStart = project.trimStart;
+ const trimEnd = project.trimEnd;
+ const cuts = project.cuts;
+ const source = getCutterProjectSource(filePath);
+ if (!source
+ || !isCutterExportProfile(profile)
+ || !isCutterExportEncoder(encoder)
+ || typeof audioStreamIndex !== 'number' || !Number.isInteger(audioStreamIndex) || audioStreamIndex < 0
+ || typeof trimStart !== 'number' || !Number.isFinite(trimStart)
+ || typeof trimEnd !== 'number' || !Number.isFinite(trimEnd)
+ || !Array.isArray(cuts) || cuts.length > 64
+ || cuts.some((cut) => !isPlainObject(cut)
+ || typeof cut.id !== 'string'
+ || typeof cut.start !== 'number' || !Number.isFinite(cut.start)
+ || typeof cut.end !== 'number' || !Number.isFinite(cut.end))) {
+ return null;
+ }
+ const hasSelectedAudio = info.audioStreams.some((stream) => stream.index === audioStreamIndex);
+ if ((info.hasAudio && !hasSelectedAudio) || (!info.hasAudio && audioStreamIndex !== 0)) return null;
+ let state = setTrimRange(createVideoEditorState(info.duration, info.fps), trimStart, trimEnd);
+ if (Math.abs(state.trimStart - trimStart) > 1 / info.fps || Math.abs(state.trimEnd - trimEnd) > 1 / info.fps) return null;
+ try {
+ for (const cut of cuts) {
+ state = addCutAt(state, cut.start, cut.end - cut.start).state;
+ }
+ } catch {
+ return null;
+ }
+ return {
+ source,
+ duration: info.duration,
+ fps: info.fps,
+ trimStart: state.trimStart,
+ trimEnd: state.trimEnd,
+ cuts: state.cuts.map((cut) => ({ ...cut })),
+ profile,
+ encoder,
+ audioStreamIndex,
+ };
+}
+
function cutterInputIdentityMatches(filePath: string): boolean {
const current = getCutterInputIdentity(filePath);
return cutterInputIdentitiesMatch(current, cutterPreparedInput);
@@ -3160,13 +3295,18 @@ async function performVideoEditExport(request: VideoEditExportRequest, onProgres
if (appShutdownStarted) return false;
if (!request || typeof request.inputFile !== 'string' || typeof request.outputFile !== 'string') return false;
if (!path.isAbsolute(request.inputFile) || !path.isAbsolute(request.outputFile) || !fs.existsSync(request.inputFile)) return false;
- if (path.extname(request.outputFile).toLowerCase() !== '.mp4' || pathsReferToSameFile(request.inputFile, request.outputFile)) return false;
+ if (!isCutterExportProfile(request.profile) || !isCutterExportEncoder(request.encoder)) return false;
+ const profile = getCutterExportProfile(request.profile);
+ const outputExtension = `.${profile.container}`;
+ if (path.extname(request.outputFile).toLowerCase() !== outputExtension || pathsReferToSameFile(request.inputFile, request.outputFile)) return false;
if (!Number.isFinite(request.trimStart) || !Number.isFinite(request.trimEnd) || !Array.isArray(request.cuts) || request.cuts.length > 64) return false;
if (request.cuts.some((cut) => !isPlainObject(cut) || typeof cut.id !== 'string' || !Number.isFinite(cut.start) || !Number.isFinite(cut.end))) return false;
const inputIdentity = getCutterInputIdentity(request.inputFile);
if (!cutterInputIdentitiesMatch(inputIdentity, cutterPreparedInput)) return false;
const info = await getVideoInfo(request.inputFile, currentCutterExportProcesses);
if (!info || cutterExportCancelled) return false;
+ if (!Number.isInteger(request.audioStreamIndex) || request.audioStreamIndex < 0) return false;
+ if ((info.hasAudio && !info.audioStreams.some((stream) => stream.index === request.audioStreamIndex)) || (!info.hasAudio && request.audioStreamIndex !== 0)) return false;
let state = setTrimRange(createVideoEditorState(info.duration, info.fps), request.trimStart, request.trimEnd);
if (Math.abs(state.trimStart - request.trimStart) > 1 / info.fps || Math.abs(state.trimEnd - request.trimEnd) > 1 / info.fps) return false;
try {
@@ -3183,12 +3323,23 @@ async function performVideoEditExport(request: VideoEditExportRequest, onProgres
const inputBytes = fs.statSync(request.inputFile).size;
const diskCheck = ensureDiskSpace(outputDir, Math.max(128 * 1024 * 1024, Math.ceil(inputBytes * 1.25)), 'Video-Editor');
if (!diskCheck.success) return false;
- const partialFile = path.join(outputDir, `.${path.basename(request.outputFile, '.mp4')}.${process.pid}.${Date.now()}.tvm-edit.mp4`);
- const plan = createCutterExportPlan({ inputFile: request.inputFile, outputFile: partialFile, segments, hasAudio: info.hasAudio });
+ const partialFile = path.join(outputDir, `.${path.basename(request.outputFile, outputExtension)}.${process.pid}.${Date.now()}.tvm-edit${outputExtension}`);
+ const availableHardwareEncoders = request.encoder === 'software' ? [] : await getCutterHardwareEncoders();
+ let plan = createCutterExportPlan({
+ inputFile: request.inputFile,
+ outputFile: partialFile,
+ segments,
+ hasAudio: info.hasAudio,
+ profile: request.profile,
+ encoder: request.encoder,
+ availableHardwareEncoders,
+ audioStreamIndex: request.audioStreamIndex,
+ rotation: info.rotation,
+ });
if (plan.filterComplex.length > 24000 || cutterExportCancelled) return false;
currentCutterPartialFile = partialFile;
- const success = await new Promise((resolve) => {
- const proc = spawn(getFFmpegPath(), plan.ffmpegArgs, { windowsHide: true });
+ const runPlan = async (activePlan: ReturnType): Promise => await new Promise((resolve) => {
+ const proc = spawn(getFFmpegPath(), activePlan.ffmpegArgs, { windowsHide: true });
currentCutterProcess = proc;
currentCutterExportProcesses.add(proc);
proc.stderr?.resume();
@@ -3199,7 +3350,7 @@ async function performVideoEditExport(request: VideoEditExportRequest, onProgres
stdout = lines.pop() || '';
for (const line of lines) {
const match = line.match(/^out_time_(?:us|ms)=(\d+)$/);
- if (match) onProgress(calculateCutterExportProgress(Number(match[1]) / 1_000_000, plan));
+ if (match) onProgress(calculateCutterExportProgress(Number(match[1]) / 1_000_000, activePlan));
}
});
proc.on('close', (code) => {
@@ -3213,6 +3364,21 @@ async function performVideoEditExport(request: VideoEditExportRequest, onProgres
resolve(false);
});
});
+ let success = await runPlan(plan);
+ if (!success && !cutterExportCancelled && plan.selectedEncoder !== 'libx264' && plan.selectedEncoder !== 'ffv1') {
+ try { fs.rmSync(partialFile, { force: true }); } catch { }
+ plan = createCutterExportPlan({
+ inputFile: request.inputFile,
+ outputFile: partialFile,
+ segments,
+ hasAudio: info.hasAudio,
+ profile: request.profile,
+ encoder: 'software',
+ audioStreamIndex: request.audioStreamIndex,
+ rotation: info.rotation,
+ });
+ success = await runPlan(plan);
+ }
if (!success || !fs.existsSync(partialFile) || fs.statSync(partialFile).size <= 256) {
fs.rmSync(partialFile, { force: true });
currentCutterPartialFile = null;
@@ -5733,7 +5899,7 @@ async function downloadLiveStream(
const outputs: string[] = [];
let partNumber = 1;
let resumeCount = 0;
- let lastPartResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') };
+ let failedPartResult: DownloadResult | null = null;
try {
// Resume loop. Each iteration runs streamlink once. On clean exit,
@@ -5760,7 +5926,7 @@ async function downloadLiveStream(
const partStartedAt = Date.now();
appendDebugLog('recording-part-start', { itemId: item.id, partNumber, filename: path.basename(partFilename) });
- lastPartResult = await downloadVODPart(item.url, partFilename, null, null, wrappedProgress, item.id, partNumber, partNumber);
+ const partResult = await downloadVODPart(item.url, partFilename, null, null, wrappedProgress, item.id, partNumber, partNumber);
// Accumulate this part's final bytes into the running total so
// the next part's meta line continues from the correct figure.
@@ -5774,6 +5940,7 @@ async function downloadLiveStream(
outputs.push(partFilename);
accumulatedBytes += partFinalBytes;
} else {
+ failedPartResult = partResult;
// Streamlink produced no bytes — likely permission or auth
// failure. Skip resume because retrying will hit the same
// wall. The error from lastPartResult will surface upstream.
@@ -5842,7 +6009,7 @@ async function downloadLiveStream(
stopLiveEventsTracker(item.id, {
success: outputs.length > 0,
durationMs: Date.now() - recordingStartedAt,
- error: outputs.length === 0 ? lastPartResult.error : undefined
+ error: outputs.length === 0 ? failedPartResult?.error ?? tBackend('unknownDownloadError') : undefined
});
}
@@ -5864,7 +6031,7 @@ async function downloadLiveStream(
});
}
- if (outputs.length === 0) return lastPartResult;
+ if (outputs.length === 0) return failedPartResult ?? { success: false, error: tBackend('unknownDownloadError') };
// Auto-merge resumed parts. Only attempt when (a) the user opted in,
// (b) there's actually something to merge, and (c) the parts are all
@@ -8298,33 +8465,86 @@ ipcMain.handle('cancel-video-editor-assets', (event, jobId: number) => {
return true;
});
+ipcMain.handle('get-cutter-project-recovery', (event, capability: string) => {
+ if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
+ const filePath = resolveFileCapability(event, capability, 'cutter-input');
+ const source = filePath ? getCutterProjectSource(filePath) : null;
+ return source ? cutterProjectAutosaves.find(source) : null;
+});
+
+ipcMain.handle('save-cutter-project', async (event, capability: string, value: unknown) => {
+ if (!isTrustedRendererEvent(event) || appShutdownStarted) return false;
+ const filePath = resolveFileCapability(event, capability, 'cutter-input');
+ if (!filePath || !cutterInputIdentityMatches(filePath) || !cutterMediaJob || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), cutterMediaJob.identity)) return false;
+ const project = createCutterProject(filePath, cutterMediaJob.info, value);
+ if (!project) return false;
+ try {
+ cutterProjectAutosaves.save(project);
+ return true;
+ } catch (error) {
+ appendDebugLog('cutter-project-save-failed', String(error));
+ return false;
+ }
+});
+
+ipcMain.handle('discard-cutter-project', (event, capability: string) => {
+ if (!isTrustedRendererEvent(event) || appShutdownStarted) return false;
+ const filePath = resolveFileCapability(event, capability, 'cutter-input');
+ const source = filePath ? getCutterProjectSource(filePath) : null;
+ if (!source) return false;
+ try {
+ return cutterProjectAutosaves.discard(source);
+ } catch (error) {
+ appendDebugLog('cutter-project-discard-failed', String(error));
+ return false;
+ }
+});
+
+ipcMain.handle('open-cutter-project', (event, capability: string) => {
+ if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
+ const filePath = resolveFileCapability(event, capability, 'cutter-input');
+ const source = filePath ? getCutterProjectSource(filePath) : null;
+ return source ? cutterProjectAutosaves.find(source) : null;
+});
+
+ipcMain.handle('get-cutter-export-options', async (event) => {
+ if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
+ return { profiles: CUTTER_EXPORT_PROFILES, hardwareEncoders: await getCutterHardwareEncoders() };
+});
+
ipcMain.handle('export-video-edit', async (event, request: RendererVideoEditExportRequest) => {
if (!isTrustedRendererEvent(event) || appShutdownStarted || !request || typeof request.inputCapability !== 'string') return { success: false, outputName: null };
const inputFile = resolveFileCapability(event, request.inputCapability, 'cutter-input');
if (!inputFile || !cutterInputIdentityMatches(inputFile)) return { success: false, outputName: null };
+ const profile = request.profile ?? 'balanced';
+ const encoder = request.encoder ?? 'software';
+ const audioStreamIndex = request.audioStreamIndex ?? 0;
+ if (!isCutterExportProfile(profile) || !isCutterExportEncoder(encoder) || !Number.isInteger(audioStreamIndex) || audioStreamIndex < 0) return { success: false, outputName: null };
+ const profileDefinition = getCutterExportProfile(profile);
+ const extension = profileDefinition.container;
let outputFile: string | null = null;
const testRoot = process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT;
if (testRoot && typeof request.outputName === 'string' && path.basename(request.outputName) === request.outputName) {
const candidate = path.join(testRoot, request.outputName);
if (isPathInsideDirectory(testRoot, candidate)) {
- const outputCapability = issueFileCapability(event, 'cutter-output', candidate, 'output-file', ['mp4']);
+ const outputCapability = issueFileCapability(event, 'cutter-output', candidate, 'output-file', [extension]);
outputFile = resolveFileCapability(event, outputCapability.token, 'cutter-output', true, [inputFile]);
}
} else {
- const defaultName = path.join(path.dirname(inputFile), `${path.basename(inputFile, path.extname(inputFile))}_edited.mp4`);
+ const defaultName = path.join(path.dirname(inputFile), `${path.basename(inputFile, path.extname(inputFile))}_edited.${extension}`);
const result = await dialog.showSaveDialog(mainWindow!, {
defaultPath: defaultName,
- filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
+ filters: [{ name: `${profileDefinition.label} Video`, extensions: [extension] }],
});
if (result.canceled || !result.filePath) return { success: false, outputName: null, cancelled: true };
- const outputCapability = issueFileCapability(event, 'cutter-output', result.filePath, 'output-file', ['mp4']);
+ const outputCapability = issueFileCapability(event, 'cutter-output', result.filePath, 'output-file', [extension]);
outputFile = resolveFileCapability(event, outputCapability.token, 'cutter-output', true, [inputFile]);
}
if (!outputFile) return { success: false, outputName: null };
- const outcome = await exportVideoEdit({ inputFile, outputFile, trimStart: request.trimStart, trimEnd: request.trimEnd, cuts: request.cuts }, (percent) => {
+ const outcome = await exportVideoEdit({ inputFile, outputFile, trimStart: request.trimStart, trimEnd: request.trimEnd, cuts: request.cuts, profile, encoder, audioStreamIndex }, (percent) => {
mainWindow?.webContents.send('cut-progress', percent);
});
- const outputCapability = outcome.success ? issueFileCapability(event, 'show-in-folder', outputFile, 'input-file', ['mp4']) : null;
+ const outputCapability = outcome.success ? issueFileCapability(event, 'show-in-folder', outputFile, 'input-file', [extension]) : null;
return { success: outcome.success, outputCapability: outputCapability?.token, outputName: outcome.success ? path.basename(outputFile) : null, cancelled: outcome.cancelled || undefined };
});
@@ -8440,8 +8660,6 @@ app.whenReady().then(() => {
startDebugLogFlushTimer();
try {
- const { openDatabase } = require('./main/infra/db');
- const { migrateJsonToSqlite } = require('./main/domain/migrator');
const dbPath = path.join(APPDATA_DIR, 'app.db');
const database: DbHandle = openDatabase(dbPath);
appDb = database;
diff --git a/src/main/domain/cutter-export.test.ts b/src/main/domain/cutter-export.test.ts
index a55f909..acafd71 100644
--- a/src/main/domain/cutter-export.test.ts
+++ b/src/main/domain/cutter-export.test.ts
@@ -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']);
+ });
});
diff --git a/src/main/domain/cutter-export.ts b/src/main/domain/cutter-export.ts
index 8891eba..50ec9c9 100644
--- a/src/main/domain/cutter-export.ts
+++ b/src/main/domain/cutter-export.ts
@@ -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, 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, 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,
};
}
diff --git a/src/main/domain/cutter-project.test.ts b/src/main/domain/cutter-project.test.ts
new file mode 100644
index 0000000..fef6abc
--- /dev/null
+++ b/src/main/domain/cutter-project.test.ts
@@ -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();
+ });
+});
diff --git a/src/main/domain/cutter-project.ts b/src/main/domain/cutter-project.ts
new file mode 100644
index 0000000..302e650
--- /dev/null
+++ b/src/main/domain/cutter-project.ts
@@ -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;
+ 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;
+ 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).id === 'string'
+ && typeof (cut as Record).start === 'number'
+ && Number.isFinite((cut as Record).start)
+ && typeof (cut as Record).end === 'number'
+ && Number.isFinite((cut as Record).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;
+ 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;
+ },
+ };
+}
diff --git a/src/preload.ts b/src/preload.ts
index 7b33274..efa6cbf 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -193,6 +193,11 @@ contextBridge.exposeInMainWorld('api', {
prepareVideoEditorWaveform: (capability: string, jobId: number): Promise => ipcRenderer.invoke('prepare-video-editor-waveform', capability, jobId),
prepareVideoEditorAssets: (capability: string, jobId: number, profile: VideoEditorAssetProfile): Promise => ipcRenderer.invoke('prepare-video-editor-assets', capability, jobId, profile),
cancelVideoEditorAssets: (jobId: number): Promise => ipcRenderer.invoke('cancel-video-editor-assets', jobId),
+ getCutterProjectRecovery: (capability: string): Promise => ipcRenderer.invoke('get-cutter-project-recovery', capability),
+ saveCutterProject: (capability: string, project: Omit): Promise => ipcRenderer.invoke('save-cutter-project', capability, project),
+ discardCutterProject: (capability: string): Promise => ipcRenderer.invoke('discard-cutter-project', capability),
+ openCutterProject: (capability: string): Promise => ipcRenderer.invoke('open-cutter-project', capability),
+ getCutterExportOptions: (): Promise => ipcRenderer.invoke('get-cutter-export-options'),
exportVideoEdit: (request: VideoEditExportRequest): Promise<{ success: boolean; outputCapability?: string; outputName: string | null; cancelled?: boolean }> => ipcRenderer.invoke('export-video-edit', request),
cancelVideoEdit: (): Promise => ipcRenderer.invoke('cancel-video-edit'),
cutVideo: (inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }> =>
diff --git a/src/renderer-cutter.ts b/src/renderer-cutter.ts
index bbac8c0..cce2be4 100644
--- a/src/renderer-cutter.ts
+++ b/src/renderer-cutter.ts
@@ -61,6 +61,12 @@ let cutterScrubSeekInFlight = false;
let cutterScrubResumePlayback = false;
let cutterScrubGeneration = 0;
let cutterDiscardResolver: ((discard: boolean) => void) | null = null;
+let cutterExportProfile: 'quality' | 'balanced' | 'fast' | 'archive' = 'balanced';
+let cutterExportEncoder: 'software' | 'h264_nvenc' | 'h264_qsv' | 'h264_amf' = 'software';
+let cutterAudioStreamIndex = 0;
+let cutterPendingProject: CutterProject | null = null;
+let cutterAutosaveTimer: number | null = null;
+let cutterExportOptions: CutterExportOptions | null = null;
const cutterMaximumCuts = 64;
const cutterFrameTolerance = 1e-8;
@@ -139,12 +145,194 @@ function getCutterPlayableDuration(): number {
return Math.max(0, cutterEditorState.trimEnd - cutterEditorState.trimStart - removed);
}
+function getCutterProjectPayload(): Omit | null {
+ if (!cutterEditorState) return null;
+ return {
+ trimStart: cutterEditorState.trimStart,
+ trimEnd: cutterEditorState.trimEnd,
+ cuts: cutterEditorState.cuts.map((cut) => ({ ...cut })),
+ profile: cutterExportProfile,
+ encoder: cutterExportEncoder,
+ audioStreamIndex: cutterAudioStreamIndex,
+ };
+}
+
+async function persistCutterProject(showResult: boolean): Promise {
+ const file = cutterFile;
+ const project = getCutterProjectPayload();
+ if (!file || !project) return false;
+ let saved = false;
+ try {
+ saved = await window.api.saveCutterProject(file.token, project);
+ } catch { }
+ if (showResult) showAppToast(saved ? 'Projekt gespeichert' : 'Projekt konnte nicht gespeichert werden', saved ? 'info' : 'warn');
+ return saved;
+}
+
+function scheduleCutterAutosave(): void {
+ if (cutterAutosaveTimer !== null) window.clearTimeout(cutterAutosaveTimer);
+ const file = cutterFile;
+ cutterAutosaveTimer = window.setTimeout(() => {
+ cutterAutosaveTimer = null;
+ if (!file || cutterFile !== file) return;
+ void persistCutterProject(false);
+ }, 500);
+}
+
+function renderCutterProjectRecovery(project: CutterProject | null): void {
+ cutterPendingProject = project;
+ const panel = byId('cutterRecoveryPanel');
+ panel.hidden = !project;
+ if (project) byId('cutterRecoveryText').textContent = 'Gespeicherte Bearbeitung gefunden';
+}
+
+function updateCutterAudioStreams(): void {
+ const select = byId('cutterAudioStream');
+ const streams = cutterVideoInfo?.audioStreams ?? [];
+ select.replaceChildren();
+ if (streams.length === 0) {
+ const option = document.createElement('option');
+ option.value = '0';
+ option.textContent = 'Keine Audiospur';
+ select.append(option);
+ select.disabled = true;
+ cutterAudioStreamIndex = 0;
+ return;
+ }
+ streams.forEach((stream) => {
+ const option = document.createElement('option');
+ option.value = String(stream.index);
+ const details = [stream.language, stream.codec, stream.channels > 0 ? `${stream.channels} Kanäle` : ''].filter(Boolean).join(' · ');
+ option.textContent = `Audiospur ${stream.index + 1}${details ? ` (${details})` : ''}`;
+ select.append(option);
+ });
+ if (!streams.some((stream) => stream.index === cutterAudioStreamIndex)) cutterAudioStreamIndex = streams[0].index;
+ select.value = String(cutterAudioStreamIndex);
+ select.disabled = false;
+}
+
+function updateCutterExportControls(options: CutterExportOptions | null): void {
+ const profile = byId('cutterExportProfile');
+ const encoder = byId('cutterExportEncoder');
+ if (options) {
+ profile.replaceChildren(...options.profiles.map((entry) => {
+ const option = document.createElement('option');
+ option.value = entry.id;
+ option.textContent = entry.label;
+ return option;
+ }));
+ }
+ profile.value = cutterExportProfile;
+ encoder.replaceChildren();
+ const software = document.createElement('option');
+ software.value = 'software';
+ software.textContent = 'Software';
+ encoder.append(software);
+ if (cutterExportProfile !== 'archive') {
+ (options?.hardwareEncoders ?? []).forEach((value) => {
+ const option = document.createElement('option');
+ option.value = value;
+ option.textContent = value === 'h264_nvenc' ? 'NVIDIA NVENC' : value === 'h264_qsv' ? 'Intel Quick Sync' : 'AMD AMF';
+ encoder.append(option);
+ });
+ }
+ if (!Array.from(encoder.options).some((option) => option.value === cutterExportEncoder)) cutterExportEncoder = 'software';
+ encoder.value = cutterExportEncoder;
+ encoder.disabled = cutterExportProfile === 'archive';
+}
+
+async function loadCutterExportOptions(file: FileCapabilityReference, generation: number): Promise {
+ let options: CutterExportOptions | null = null;
+ try {
+ options = await window.api.getCutterExportOptions();
+ } catch { }
+ if (generation !== cutterLoadGeneration || cutterFile !== file) return;
+ cutterExportOptions = options;
+ updateCutterExportControls(options);
+}
+
+function applyCutterProject(project: CutterProject): boolean {
+ if (!cutterEditorState || !cutterVideoInfo) return false;
+ if (Math.abs(project.duration - cutterEditorState.duration) > 1 / cutterEditorState.fps || Math.abs(project.fps - cutterEditorState.fps) > 0.01) return false;
+ cutterEditorState = {
+ duration: cutterEditorState.duration,
+ fps: cutterEditorState.fps,
+ trimStart: project.trimStart,
+ trimEnd: project.trimEnd,
+ cuts: project.cuts.map((cut) => ({ ...cut })),
+ };
+ cutterExportProfile = project.profile;
+ cutterExportEncoder = project.encoder;
+ cutterAudioStreamIndex = project.audioStreamIndex;
+ updateCutterAudioStreams();
+ updateCutterExportControls(cutterExportOptions);
+ cutterHistoryPast = [];
+ cutterHistoryFuture = [];
+ cutterActiveCutId = null;
+ renderCutterEditor();
+ seekCutterVideo(cutterEditorState.trimStart);
+ return true;
+}
+
+async function recoverCutterProject(): Promise {
+ if (!cutterPendingProject || !applyCutterProject(cutterPendingProject)) {
+ showAppToast('Projekt konnte nicht wiederhergestellt werden', 'warn');
+ return;
+ }
+ renderCutterProjectRecovery(null);
+ showAppToast('Projekt wiederhergestellt', 'info');
+}
+
+async function discardCutterProject(): Promise {
+ if (!cutterFile) return;
+ try { await window.api.discardCutterProject(cutterFile.token); } catch { }
+ renderCutterProjectRecovery(null);
+}
+
+async function saveCutterProject(): Promise {
+ await persistCutterProject(true);
+}
+
+async function openCutterProject(): Promise {
+ if (!cutterFile || !await persistCutterProject(false)) return;
+ let project: CutterProject | null = null;
+ try { project = await window.api.openCutterProject(cutterFile.token); } catch { }
+ if (!project || !applyCutterProject(project)) {
+ showAppToast('Kein passendes Projekt gefunden', 'warn');
+ return;
+ }
+ renderCutterProjectRecovery(null);
+ showAppToast('Projekt geöffnet', 'info');
+}
+
+function setCutterExportProfile(value: string): void {
+ if (value !== 'quality' && value !== 'balanced' && value !== 'fast' && value !== 'archive') return;
+ cutterExportProfile = value;
+ if (value === 'archive') cutterExportEncoder = 'software';
+ updateCutterExportControls(cutterExportOptions);
+ scheduleCutterAutosave();
+}
+
+function setCutterExportEncoder(value: string): void {
+ if (value !== 'software' && value !== 'h264_nvenc' && value !== 'h264_qsv' && value !== 'h264_amf') return;
+ cutterExportEncoder = value;
+ scheduleCutterAutosave();
+}
+
+function setCutterAudioStream(value: string): void {
+ const index = Number(value);
+ if (!Number.isInteger(index) || index < 0 || !(cutterVideoInfo?.audioStreams ?? []).some((stream) => stream.index === index)) return;
+ cutterAudioStreamIndex = index;
+ scheduleCutterAutosave();
+}
+
function commitCutterChange(before: CutterEditorState): void {
if (!cutterEditorState || cutterStatesEqual(before, cutterEditorState)) return;
cutterHistoryPast.push(cloneCutterState(before));
if (cutterHistoryPast.length > 100) cutterHistoryPast.shift();
cutterHistoryFuture = [];
updateCutterHistoryButtons();
+ scheduleCutterAutosave();
}
function updateCutterHistoryButtons(): void {
@@ -718,6 +906,11 @@ function setCutterControlsEnabled(enabled: boolean): void {
byId('cutterZoomInBtn').disabled = !enabled;
byId('cutterZoomOutBtn').disabled = !enabled;
byId('cutterNewCutBtn').disabled = !enabled;
+ byId('cutterSaveProjectBtn').disabled = !enabled;
+ byId('cutterOpenProjectBtn').disabled = !enabled;
+ byId('cutterExportProfile').disabled = !enabled;
+ byId('cutterExportEncoder').disabled = !enabled || cutterExportProfile === 'archive';
+ byId('cutterAudioStream').disabled = !enabled || (cutterVideoInfo?.audioStreams.length ?? 0) === 0;
const volumeControl = document.querySelector('.cutter-volume-control');
volumeControl?.classList.toggle('disabled', !enabled);
volumeControl?.setAttribute('aria-disabled', String(!enabled));
@@ -872,6 +1065,11 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise
cutterHistoryPast = [];
cutterHistoryFuture = [];
cutterActiveCutId = null;
+ cutterExportProfile = 'balanced';
+ cutterExportEncoder = 'software';
+ cutterAudioStreamIndex = media.info.audioStreams[0]?.index ?? 0;
+ renderCutterProjectRecovery(null);
+ updateCutterAudioStreams();
cutterZoom = getInitialCutterZoom(media.info.duration);
byId('cutterZoom').value = String(cutterZoom);
byId('cutterFilePath').value = file.name;
@@ -899,6 +1097,13 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise
updateCutterZoom(cutterZoom);
renderCutterEditor();
updateCutterPlayhead(0);
+ void (async () => {
+ await loadCutterExportOptions(file, generation);
+ if (generation !== cutterLoadGeneration || cutterFile !== file) return;
+ let project: CutterProject | null = null;
+ try { project = await window.api.getCutterProjectRecovery(file.token); } catch { }
+ if (generation === cutterLoadGeneration && cutterFile === file) renderCutterProjectRecovery(project);
+ })();
void requestCutterWaveform(file, media.jobId, generation);
void requestCutterAssets();
}
@@ -940,6 +1145,10 @@ function confirmCutterReplacement(file: FileCapabilityReference): Promise {
if (!file || isCutting) return;
if (!await confirmCutterReplacement(file)) return;
+ if (cutterEditorState && !await persistCutterProject(false)) {
+ showAppToast('Projekt konnte nicht gespeichert werden', 'warn');
+ return;
+ }
await loadCutterFromPath(file);
}
@@ -1023,6 +1232,7 @@ function undoCutterEdit(): void {
cutterActiveCutId = null;
seekCutterVideo(cutterEditorState.trimStart);
renderCutterEditor();
+ scheduleCutterAutosave();
}
function redoCutterEdit(): void {
@@ -1034,6 +1244,7 @@ function redoCutterEdit(): void {
cutterActiveCutId = null;
seekCutterVideo(cutterEditorState.trimStart);
renderCutterEditor();
+ scheduleCutterAutosave();
}
function setCutterPreviewMode(enabled: boolean): void {
@@ -1397,6 +1608,9 @@ async function startCutting(): Promise {
trimStart: cutterEditorState.trimStart,
trimEnd: cutterEditorState.trimEnd,
cuts: cutterEditorState.cuts.map((cut) => ({ ...cut })),
+ profile: cutterExportProfile,
+ encoder: cutterExportEncoder,
+ audioStreamIndex: cutterAudioStreamIndex,
});
if (result.success) {
showAppToast(UI_TEXT.cutter.exportSuccess, 'info');
diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts
index a275186..d720111 100644
--- a/src/renderer-globals.d.ts
+++ b/src/renderer-globals.d.ts
@@ -168,6 +168,8 @@ interface VideoInfo {
audioCodec: string | null;
previewCompatible: boolean;
variableFrameRate: boolean;
+ rotation: 0 | 90 | 180 | 270;
+ audioStreams: Array<{ index: number; codec: string; channels: number; language: string | null }>;
}
interface SecretStatus {
@@ -212,6 +214,26 @@ interface VideoEditExportRequest {
trimStart: number;
trimEnd: number;
cuts: Array<{ id: string; start: number; end: number }>;
+ profile?: 'quality' | 'balanced' | 'fast' | 'archive';
+ encoder?: 'software' | 'h264_nvenc' | 'h264_qsv' | 'h264_amf';
+ audioStreamIndex?: number;
+}
+
+interface CutterProject {
+ source: { path: string; size: number; mtimeMs: number };
+ duration: number;
+ fps: number;
+ trimStart: number;
+ trimEnd: number;
+ cuts: Array<{ id: string; start: number; end: number }>;
+ profile: 'quality' | 'balanced' | 'fast' | 'archive';
+ encoder: 'software' | 'h264_nvenc' | 'h264_qsv' | 'h264_amf';
+ audioStreamIndex: number;
+}
+
+interface CutterExportOptions {
+ profiles: Array<{ id: 'quality' | 'balanced' | 'fast' | 'archive'; label: string; container: 'mp4' | 'mkv' }>;
+ hardwareEncoders: Array<'h264_nvenc' | 'h264_qsv' | 'h264_amf'>;
}
interface FileCapabilityReference {
@@ -451,6 +473,11 @@ interface ApiBridge {
prepareVideoEditorWaveform(capability: string, jobId: number): Promise;
prepareVideoEditorAssets(capability: string, jobId: number, profile: VideoEditorAssetProfile): Promise;
cancelVideoEditorAssets(jobId: number): Promise;
+ getCutterProjectRecovery(capability: string): Promise;
+ saveCutterProject(capability: string, project: Omit): Promise;
+ discardCutterProject(capability: string): Promise;
+ openCutterProject(capability: string): Promise;
+ getCutterExportOptions(): Promise;
exportVideoEdit(request: VideoEditExportRequest): Promise<{ success: boolean; outputCapability?: string; outputName: string | null; cancelled?: boolean }>;
cancelVideoEdit(): Promise;
cutVideo(inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }>;
diff --git a/src/styles.css b/src/styles.css
index 6c36e4e..4b235a9 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -3174,6 +3174,23 @@ input[type="checkbox"].vod-select-checkbox {
text-overflow: ellipsis;
}
+.cutter-recovery-panel {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 8px;
+ padding: 8px 10px;
+ color: var(--workspace-text, var(--text));
+ background: var(--workspace-control, rgba(255, 255, 255, 0.04));
+ border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.1));
+ border-radius: 8px;
+ font-size: 12px;
+}
+
+.cutter-recovery-panel span {
+ flex: 1;
+}
+
.cutter-workspace {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
@@ -3354,6 +3371,25 @@ input[type="checkbox"].vod-select-checkbox {
transform: translateX(16px);
}
+.cutter-export-options {
+ padding: 11px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 6px;
+ background: var(--workspace-control, rgba(255, 255, 255, 0.04));
+ border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.08));
+ border-radius: 7px;
+}
+
+.cutter-export-options label {
+ color: var(--workspace-text-muted, var(--text-secondary));
+ font-size: 11px;
+}
+
+.cutter-export-options select {
+ min-width: 0;
+}
+
.cutter-trim-card,
.cutter-cut-section {
padding: 11px;