diff --git a/src/index.html b/src/index.html index a3e7d45..90bcfea 100644 --- a/src/index.html +++ b/src/index.html @@ -453,6 +453,13 @@ + + + +
@@ -474,6 +481,23 @@ +
+ + + + + + +
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;