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
+24
View File
@@ -453,6 +453,13 @@
<input type="text" id="cutterFilePath" readonly aria-labelledby="cutterSelectTitle" placeholder="Keine Datei ausgewählt…">
</div>
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
<button type="button" class="btn-secondary" id="cutterOpenProjectBtn" onclick="openCutterProject()" disabled>Projekt öffnen</button>
<button type="button" class="btn-secondary" id="cutterSaveProjectBtn" onclick="saveCutterProject()" disabled>Projekt speichern</button>
</div>
<div class="cutter-recovery-panel" id="cutterRecoveryPanel" role="status" hidden>
<span id="cutterRecoveryText">Gespeicherte Bearbeitung gefunden</span>
<button type="button" class="btn-secondary" onclick="recoverCutterProject()">Wiederherstellen</button>
<button type="button" class="btn-secondary" onclick="discardCutterProject()">Verwerfen</button>
</div>
<div class="cutter-workspace" id="cutterWorkspace">
@@ -474,6 +481,23 @@
<input type="checkbox" id="cutterPreviewMode" checked onchange="setCutterPreviewMode(this.checked)">
<span class="cutter-toggle-track" aria-hidden="true"></span>
</label>
<div class="cutter-export-options">
<label for="cutterExportProfile">Exportprofil</label>
<select id="cutterExportProfile" onchange="setCutterExportProfile(this.value)" disabled>
<option value="quality">Quality</option>
<option value="balanced" selected>Balanced</option>
<option value="fast">Fast</option>
<option value="archive">Archive</option>
</select>
<label for="cutterExportEncoder">Encoder</label>
<select id="cutterExportEncoder" onchange="setCutterExportEncoder(this.value)" disabled>
<option value="software">Software</option>
</select>
<label for="cutterAudioStream">Audiospur</label>
<select id="cutterAudioStream" onchange="setCutterAudioStream(this.value)" disabled>
<option value="0">Keine Audiospur</option>
</select>
</div>
<div class="cutter-trim-card">
<div class="cutter-card-title" id="cutterGlobalTrimLabel">Gesamtauswahl</div>
<div class="cutter-time-field-row">
+241 -23
View File
@@ -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<CutterHardwareEncoder[]> | 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<VodStoryboard | null> {
return null;
}
let manifest: StoryboardManifestEntry[] | null = null;
let manifest: StoryboardManifestEntry[];
try {
const manifestResp = await axios.get<StoryboardManifestEntry[]>(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<ChildProcess>, timeoutMs = 30000): Promise<VideoInfo | null> {
const ffmpegReady = await ensureFfmpegInstalled();
if (!ffmpegReady) {
@@ -2826,7 +2854,15 @@ async function getVideoInfo(filePath: string, trackedProcesses?: Set<ChildProces
try {
const info = JSON.parse(output);
const videoStream = info.streams?.find((s: any) => 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<ChildProces
}
const videoCodec = String(videoStream.codec_name || '').toLowerCase();
const audioCodec = audioStream ? String(audioStream.codec_name || '').toLowerCase() : null;
const audioCodec = audioStream?.codec ?? null;
const variableFrameRate = averageFps > 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<ChildProces
audioCodec,
previewCompatible: isVideoEditorPreviewCompatible(filePath, videoCodec, audioCodec),
variableFrameRate,
rotation: normalizeCutterRotation(rotationData ?? videoStream.tags?.rotate),
audioStreams,
});
} catch {
finish(null);
@@ -2860,6 +2901,40 @@ async function getVideoInfo(filePath: string, trackedProcesses?: Set<ChildProces
});
}
async function getCutterHardwareEncoders(): Promise<CutterHardwareEncoder[]> {
if (cutterHardwareEncoderProbe) return await cutterHardwareEncoderProbe;
cutterHardwareEncoderProbe = (async (): Promise<CutterHardwareEncoder[]> => {
if (!await ensureFfmpegInstalled()) return [];
return await new Promise<CutterHardwareEncoder[]>((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<string, unknown>;
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<boolean>((resolve) => {
const proc = spawn(getFFmpegPath(), plan.ffmpegArgs, { windowsHide: true });
const runPlan = async (activePlan: ReturnType<typeof createCutterExportPlan>): Promise<boolean> => await new Promise<boolean>((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;
+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;
},
};
}
+5
View File
@@ -193,6 +193,11 @@ contextBridge.exposeInMainWorld('api', {
prepareVideoEditorWaveform: (capability: string, jobId: number): Promise<VideoEditorWaveform | null> => ipcRenderer.invoke('prepare-video-editor-waveform', capability, jobId),
prepareVideoEditorAssets: (capability: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null> => ipcRenderer.invoke('prepare-video-editor-assets', capability, jobId, profile),
cancelVideoEditorAssets: (jobId: number): Promise<boolean> => ipcRenderer.invoke('cancel-video-editor-assets', jobId),
getCutterProjectRecovery: (capability: string): Promise<CutterProject | null> => ipcRenderer.invoke('get-cutter-project-recovery', capability),
saveCutterProject: (capability: string, project: Omit<CutterProject, 'source' | 'duration' | 'fps'>): Promise<boolean> => ipcRenderer.invoke('save-cutter-project', capability, project),
discardCutterProject: (capability: string): Promise<boolean> => ipcRenderer.invoke('discard-cutter-project', capability),
openCutterProject: (capability: string): Promise<CutterProject | null> => ipcRenderer.invoke('open-cutter-project', capability),
getCutterExportOptions: (): Promise<CutterExportOptions | null> => 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<boolean> => ipcRenderer.invoke('cancel-video-edit'),
cutVideo: (inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }> =>
+214
View File
@@ -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<CutterProject, 'source' | 'duration' | 'fps'> | 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<boolean> {
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<HTMLElement>('cutterRecoveryPanel');
panel.hidden = !project;
if (project) byId('cutterRecoveryText').textContent = 'Gespeicherte Bearbeitung gefunden';
}
function updateCutterAudioStreams(): void {
const select = byId<HTMLSelectElement>('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<HTMLSelectElement>('cutterExportProfile');
const encoder = byId<HTMLSelectElement>('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<void> {
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<void> {
if (!cutterPendingProject || !applyCutterProject(cutterPendingProject)) {
showAppToast('Projekt konnte nicht wiederhergestellt werden', 'warn');
return;
}
renderCutterProjectRecovery(null);
showAppToast('Projekt wiederhergestellt', 'info');
}
async function discardCutterProject(): Promise<void> {
if (!cutterFile) return;
try { await window.api.discardCutterProject(cutterFile.token); } catch { }
renderCutterProjectRecovery(null);
}
async function saveCutterProject(): Promise<void> {
await persistCutterProject(true);
}
async function openCutterProject(): Promise<void> {
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<HTMLButtonElement>('cutterZoomInBtn').disabled = !enabled;
byId<HTMLButtonElement>('cutterZoomOutBtn').disabled = !enabled;
byId<HTMLButtonElement>('cutterNewCutBtn').disabled = !enabled;
byId<HTMLButtonElement>('cutterSaveProjectBtn').disabled = !enabled;
byId<HTMLButtonElement>('cutterOpenProjectBtn').disabled = !enabled;
byId<HTMLSelectElement>('cutterExportProfile').disabled = !enabled;
byId<HTMLSelectElement>('cutterExportEncoder').disabled = !enabled || cutterExportProfile === 'archive';
byId<HTMLSelectElement>('cutterAudioStream').disabled = !enabled || (cutterVideoInfo?.audioStreams.length ?? 0) === 0;
const volumeControl = document.querySelector<HTMLElement>('.cutter-volume-control');
volumeControl?.classList.toggle('disabled', !enabled);
volumeControl?.setAttribute('aria-disabled', String(!enabled));
@@ -872,6 +1065,11 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise<void>
cutterHistoryPast = [];
cutterHistoryFuture = [];
cutterActiveCutId = null;
cutterExportProfile = 'balanced';
cutterExportEncoder = 'software';
cutterAudioStreamIndex = media.info.audioStreams[0]?.index ?? 0;
renderCutterProjectRecovery(null);
updateCutterAudioStreams();
cutterZoom = getInitialCutterZoom(media.info.duration);
byId<HTMLInputElement>('cutterZoom').value = String(cutterZoom);
byId<HTMLInputElement>('cutterFilePath').value = file.name;
@@ -899,6 +1097,13 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise<void>
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<boolea
async function requestCutterVideoReplacement(file: FileCapabilityReference): Promise<void> {
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<void> {
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');
+27
View File
@@ -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<VideoEditorWaveform | null>;
prepareVideoEditorAssets(capability: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null>;
cancelVideoEditorAssets(jobId: number): Promise<boolean>;
getCutterProjectRecovery(capability: string): Promise<CutterProject | null>;
saveCutterProject(capability: string, project: Omit<CutterProject, 'source' | 'duration' | 'fps'>): Promise<boolean>;
discardCutterProject(capability: string): Promise<boolean>;
openCutterProject(capability: string): Promise<CutterProject | null>;
getCutterExportOptions(): Promise<CutterExportOptions | null>;
exportVideoEdit(request: VideoEditExportRequest): Promise<{ success: boolean; outputCapability?: string; outputName: string | null; cancelled?: boolean }>;
cancelVideoEdit(): Promise<boolean>;
cutVideo(inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }>;
+36
View File
@@ -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;