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:
@@ -453,6 +453,13 @@
|
|||||||
<input type="text" id="cutterFilePath" readonly aria-labelledby="cutterSelectTitle" placeholder="Keine Datei ausgewählt…">
|
<input type="text" id="cutterFilePath" readonly aria-labelledby="cutterSelectTitle" placeholder="Keine Datei ausgewählt…">
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
|
<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>
|
||||||
|
|
||||||
<div class="cutter-workspace" id="cutterWorkspace">
|
<div class="cutter-workspace" id="cutterWorkspace">
|
||||||
@@ -474,6 +481,23 @@
|
|||||||
<input type="checkbox" id="cutterPreviewMode" checked onchange="setCutterPreviewMode(this.checked)">
|
<input type="checkbox" id="cutterPreviewMode" checked onchange="setCutterPreviewMode(this.checked)">
|
||||||
<span class="cutter-toggle-track" aria-hidden="true"></span>
|
<span class="cutter-toggle-track" aria-hidden="true"></span>
|
||||||
</label>
|
</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-trim-card">
|
||||||
<div class="cutter-card-title" id="cutterGlobalTrimLabel">Gesamtauswahl</div>
|
<div class="cutter-card-title" id="cutterGlobalTrimLabel">Gesamtauswahl</div>
|
||||||
<div class="cutter-time-field-row">
|
<div class="cutter-time-field-row">
|
||||||
|
|||||||
+241
-23
@@ -21,7 +21,7 @@ import { watchRendererChanges } from './main/dev-reload';
|
|||||||
import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output';
|
import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output';
|
||||||
import { PartialDownloadRegistry } from './main/domain/partial-download';
|
import { PartialDownloadRegistry } from './main/domain/partial-download';
|
||||||
import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './main/queue/process-registry';
|
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 {
|
import {
|
||||||
normalizeLogin,
|
normalizeLogin,
|
||||||
normalizeAutoRecordPollSeconds,
|
normalizeAutoRecordPollSeconds,
|
||||||
@@ -40,7 +40,17 @@ import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, Do
|
|||||||
import { buildVodPreviewFrameUrls } from './main/domain/vod-preview';
|
import { buildVodPreviewFrameUrls } from './main/domain/vod-preview';
|
||||||
import { getWindowsAppIdentity } from './main/domain/app-identity';
|
import { getWindowsAppIdentity } from './main/domain/app-identity';
|
||||||
import { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange, type EditorCut } from './main/domain/video-editor';
|
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 {
|
import {
|
||||||
CUTTER_SESSION_CAPABILITY_TTL_MS,
|
CUTTER_SESSION_CAPABILITY_TTL_MS,
|
||||||
FileCapabilityStore,
|
FileCapabilityStore,
|
||||||
@@ -56,6 +66,7 @@ import { createExportableConfig } from './main/domain/config-export';
|
|||||||
import { commitQueueMutation, persistStateChange } from './main/domain/persistence-commit';
|
import { commitQueueMutation, persistStateChange } from './main/domain/persistence-commit';
|
||||||
import { resolveSecretInputUpdate } from './main/domain/secret-input';
|
import { resolveSecretInputUpdate } from './main/domain/secret-input';
|
||||||
import { createSecretStore, type SecretStore } from './main/domain/secret-store';
|
import { createSecretStore, type SecretStore } from './main/domain/secret-store';
|
||||||
|
import { migrateJsonToSqlite } from './main/domain/migrator';
|
||||||
import { createElectronSecureStorage } from './main/infra/secure-storage';
|
import { createElectronSecureStorage } from './main/infra/secure-storage';
|
||||||
import { readChatFile } from './main/domain/chat-reader';
|
import { readChatFile } from './main/domain/chat-reader';
|
||||||
import {
|
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 APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
|
||||||
const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log');
|
const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log');
|
||||||
const PARTIAL_DOWNLOADS_FILE = path.join(APPDATA_DIR, 'partial-downloads.json');
|
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_DIR = path.join(APPDATA_DIR, 'tools');
|
||||||
const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink');
|
const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink');
|
||||||
const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg');
|
const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg');
|
||||||
@@ -141,6 +153,7 @@ if (!fs.existsSync(APPDATA_DIR)) {
|
|||||||
fs.mkdirSync(APPDATA_DIR, { recursive: true });
|
fs.mkdirSync(APPDATA_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
const partialDownloadRegistry = new PartialDownloadRegistry(PARTIAL_DOWNLOADS_FILE);
|
const partialDownloadRegistry = new PartialDownloadRegistry(PARTIAL_DOWNLOADS_FILE);
|
||||||
|
const cutterProjectAutosaves = createCutterProjectAutosaveStore(CUTTER_PROJECT_AUTOSAVE_FILE);
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// INTERFACES
|
// INTERFACES
|
||||||
@@ -274,6 +287,8 @@ interface VideoInfo {
|
|||||||
audioCodec: string | null;
|
audioCodec: string | null;
|
||||||
previewCompatible: boolean;
|
previewCompatible: boolean;
|
||||||
variableFrameRate: boolean;
|
variableFrameRate: boolean;
|
||||||
|
rotation: 0 | 90 | 180 | 270;
|
||||||
|
audioStreams: Array<{ index: number; codec: string; channels: number; language: string | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VideoEditorMedia {
|
interface VideoEditorMedia {
|
||||||
@@ -312,6 +327,9 @@ interface VideoEditExportRequest {
|
|||||||
trimStart: number;
|
trimStart: number;
|
||||||
trimEnd: number;
|
trimEnd: number;
|
||||||
cuts: EditorCut[];
|
cuts: EditorCut[];
|
||||||
|
profile: CutterExportProfile;
|
||||||
|
encoder: CutterExportEncoder;
|
||||||
|
audioStreamIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RendererVideoEditExportRequest {
|
interface RendererVideoEditExportRequest {
|
||||||
@@ -320,6 +338,9 @@ interface RendererVideoEditExportRequest {
|
|||||||
trimStart: number;
|
trimStart: number;
|
||||||
trimEnd: number;
|
trimEnd: number;
|
||||||
cuts: EditorCut[];
|
cuts: EditorCut[];
|
||||||
|
profile?: CutterExportProfile;
|
||||||
|
encoder?: CutterExportEncoder;
|
||||||
|
audioStreamIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReleaseUpdateInfo {
|
interface ReleaseUpdateInfo {
|
||||||
@@ -735,6 +756,7 @@ let currentCutterProcess: ChildProcess | null = null;
|
|||||||
let currentCutterPartialFile: string | null = null;
|
let currentCutterPartialFile: string | null = null;
|
||||||
let cutterExportActive = false;
|
let cutterExportActive = false;
|
||||||
let cutterExportCancelled = 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 cutterPreparedInput: { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null = null;
|
||||||
let cutterMediaGeneration = 0;
|
let cutterMediaGeneration = 0;
|
||||||
let cutterMediaRequestGeneration = 0;
|
let cutterMediaRequestGeneration = 0;
|
||||||
@@ -2629,7 +2651,7 @@ async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let manifest: StoryboardManifestEntry[] | null = null;
|
let manifest: StoryboardManifestEntry[];
|
||||||
try {
|
try {
|
||||||
const manifestResp = await axios.get<StoryboardManifestEntry[]>(manifestUrl, {
|
const manifestResp = await axios.get<StoryboardManifestEntry[]>(manifestUrl, {
|
||||||
timeout: 6000,
|
timeout: 6000,
|
||||||
@@ -2771,6 +2793,12 @@ function isSupportedVideoEditorInput(filePath: string): boolean {
|
|||||||
return ['.mp4', '.m4v', '.mov', '.webm', '.mkv', '.ts', '.avi'].includes(path.extname(filePath).toLowerCase());
|
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> {
|
async function getVideoInfo(filePath: string, trackedProcesses?: Set<ChildProcess>, timeoutMs = 30000): Promise<VideoInfo | null> {
|
||||||
const ffmpegReady = await ensureFfmpegInstalled();
|
const ffmpegReady = await ensureFfmpegInstalled();
|
||||||
if (!ffmpegReady) {
|
if (!ffmpegReady) {
|
||||||
@@ -2826,7 +2854,15 @@ async function getVideoInfo(filePath: string, trackedProcesses?: Set<ChildProces
|
|||||||
try {
|
try {
|
||||||
const info = JSON.parse(output);
|
const info = JSON.parse(output);
|
||||||
const videoStream = info.streams?.find((s: any) => s.codec_type === 'video');
|
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 duration = parseFloat(info.format?.duration || videoStream?.duration || '0');
|
||||||
const averageFps = parseFrameRate(videoStream?.avg_frame_rate);
|
const averageFps = parseFrameRate(videoStream?.avg_frame_rate);
|
||||||
const realFps = parseFrameRate(videoStream?.r_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 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 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({
|
finish({
|
||||||
duration,
|
duration,
|
||||||
width: videoStream.width,
|
width: videoStream.width,
|
||||||
@@ -2850,6 +2889,8 @@ async function getVideoInfo(filePath: string, trackedProcesses?: Set<ChildProces
|
|||||||
audioCodec,
|
audioCodec,
|
||||||
previewCompatible: isVideoEditorPreviewCompatible(filePath, videoCodec, audioCodec),
|
previewCompatible: isVideoEditorPreviewCompatible(filePath, videoCodec, audioCodec),
|
||||||
variableFrameRate,
|
variableFrameRate,
|
||||||
|
rotation: normalizeCutterRotation(rotationData ?? videoStream.tags?.rotate),
|
||||||
|
audioStreams,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
finish(null);
|
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 {
|
function cancelCutterMediaPreparation(): void {
|
||||||
cutterAssetRunGeneration += 1;
|
cutterAssetRunGeneration += 1;
|
||||||
for (const process of currentCutterMediaProcesses) {
|
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 {
|
function cutterInputIdentityMatches(filePath: string): boolean {
|
||||||
const current = getCutterInputIdentity(filePath);
|
const current = getCutterInputIdentity(filePath);
|
||||||
return cutterInputIdentitiesMatch(current, cutterPreparedInput);
|
return cutterInputIdentitiesMatch(current, cutterPreparedInput);
|
||||||
@@ -3160,13 +3295,18 @@ async function performVideoEditExport(request: VideoEditExportRequest, onProgres
|
|||||||
if (appShutdownStarted) return false;
|
if (appShutdownStarted) return false;
|
||||||
if (!request || typeof request.inputFile !== 'string' || typeof request.outputFile !== 'string') 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.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 (!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;
|
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);
|
const inputIdentity = getCutterInputIdentity(request.inputFile);
|
||||||
if (!cutterInputIdentitiesMatch(inputIdentity, cutterPreparedInput)) return false;
|
if (!cutterInputIdentitiesMatch(inputIdentity, cutterPreparedInput)) return false;
|
||||||
const info = await getVideoInfo(request.inputFile, currentCutterExportProcesses);
|
const info = await getVideoInfo(request.inputFile, currentCutterExportProcesses);
|
||||||
if (!info || cutterExportCancelled) return false;
|
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);
|
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;
|
if (Math.abs(state.trimStart - request.trimStart) > 1 / info.fps || Math.abs(state.trimEnd - request.trimEnd) > 1 / info.fps) return false;
|
||||||
try {
|
try {
|
||||||
@@ -3183,12 +3323,23 @@ async function performVideoEditExport(request: VideoEditExportRequest, onProgres
|
|||||||
const inputBytes = fs.statSync(request.inputFile).size;
|
const inputBytes = fs.statSync(request.inputFile).size;
|
||||||
const diskCheck = ensureDiskSpace(outputDir, Math.max(128 * 1024 * 1024, Math.ceil(inputBytes * 1.25)), 'Video-Editor');
|
const diskCheck = ensureDiskSpace(outputDir, Math.max(128 * 1024 * 1024, Math.ceil(inputBytes * 1.25)), 'Video-Editor');
|
||||||
if (!diskCheck.success) return false;
|
if (!diskCheck.success) return false;
|
||||||
const partialFile = path.join(outputDir, `.${path.basename(request.outputFile, '.mp4')}.${process.pid}.${Date.now()}.tvm-edit.mp4`);
|
const partialFile = path.join(outputDir, `.${path.basename(request.outputFile, outputExtension)}.${process.pid}.${Date.now()}.tvm-edit${outputExtension}`);
|
||||||
const plan = createCutterExportPlan({ inputFile: request.inputFile, outputFile: partialFile, segments, hasAudio: info.hasAudio });
|
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;
|
if (plan.filterComplex.length > 24000 || cutterExportCancelled) return false;
|
||||||
currentCutterPartialFile = partialFile;
|
currentCutterPartialFile = partialFile;
|
||||||
const success = await new Promise<boolean>((resolve) => {
|
const runPlan = async (activePlan: ReturnType<typeof createCutterExportPlan>): Promise<boolean> => await new Promise<boolean>((resolve) => {
|
||||||
const proc = spawn(getFFmpegPath(), plan.ffmpegArgs, { windowsHide: true });
|
const proc = spawn(getFFmpegPath(), activePlan.ffmpegArgs, { windowsHide: true });
|
||||||
currentCutterProcess = proc;
|
currentCutterProcess = proc;
|
||||||
currentCutterExportProcesses.add(proc);
|
currentCutterExportProcesses.add(proc);
|
||||||
proc.stderr?.resume();
|
proc.stderr?.resume();
|
||||||
@@ -3199,7 +3350,7 @@ async function performVideoEditExport(request: VideoEditExportRequest, onProgres
|
|||||||
stdout = lines.pop() || '';
|
stdout = lines.pop() || '';
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const match = line.match(/^out_time_(?:us|ms)=(\d+)$/);
|
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) => {
|
proc.on('close', (code) => {
|
||||||
@@ -3213,6 +3364,21 @@ async function performVideoEditExport(request: VideoEditExportRequest, onProgres
|
|||||||
resolve(false);
|
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) {
|
if (!success || !fs.existsSync(partialFile) || fs.statSync(partialFile).size <= 256) {
|
||||||
fs.rmSync(partialFile, { force: true });
|
fs.rmSync(partialFile, { force: true });
|
||||||
currentCutterPartialFile = null;
|
currentCutterPartialFile = null;
|
||||||
@@ -5733,7 +5899,7 @@ async function downloadLiveStream(
|
|||||||
const outputs: string[] = [];
|
const outputs: string[] = [];
|
||||||
let partNumber = 1;
|
let partNumber = 1;
|
||||||
let resumeCount = 0;
|
let resumeCount = 0;
|
||||||
let lastPartResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') };
|
let failedPartResult: DownloadResult | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Resume loop. Each iteration runs streamlink once. On clean exit,
|
// Resume loop. Each iteration runs streamlink once. On clean exit,
|
||||||
@@ -5760,7 +5926,7 @@ async function downloadLiveStream(
|
|||||||
const partStartedAt = Date.now();
|
const partStartedAt = Date.now();
|
||||||
appendDebugLog('recording-part-start', { itemId: item.id, partNumber, filename: path.basename(partFilename) });
|
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
|
// Accumulate this part's final bytes into the running total so
|
||||||
// the next part's meta line continues from the correct figure.
|
// the next part's meta line continues from the correct figure.
|
||||||
@@ -5774,6 +5940,7 @@ async function downloadLiveStream(
|
|||||||
outputs.push(partFilename);
|
outputs.push(partFilename);
|
||||||
accumulatedBytes += partFinalBytes;
|
accumulatedBytes += partFinalBytes;
|
||||||
} else {
|
} else {
|
||||||
|
failedPartResult = partResult;
|
||||||
// Streamlink produced no bytes — likely permission or auth
|
// Streamlink produced no bytes — likely permission or auth
|
||||||
// failure. Skip resume because retrying will hit the same
|
// failure. Skip resume because retrying will hit the same
|
||||||
// wall. The error from lastPartResult will surface upstream.
|
// wall. The error from lastPartResult will surface upstream.
|
||||||
@@ -5842,7 +6009,7 @@ async function downloadLiveStream(
|
|||||||
stopLiveEventsTracker(item.id, {
|
stopLiveEventsTracker(item.id, {
|
||||||
success: outputs.length > 0,
|
success: outputs.length > 0,
|
||||||
durationMs: Date.now() - recordingStartedAt,
|
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,
|
// 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
|
// (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;
|
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) => {
|
ipcMain.handle('export-video-edit', async (event, request: RendererVideoEditExportRequest) => {
|
||||||
if (!isTrustedRendererEvent(event) || appShutdownStarted || !request || typeof request.inputCapability !== 'string') return { success: false, outputName: null };
|
if (!isTrustedRendererEvent(event) || appShutdownStarted || !request || typeof request.inputCapability !== 'string') return { success: false, outputName: null };
|
||||||
const inputFile = resolveFileCapability(event, request.inputCapability, 'cutter-input');
|
const inputFile = resolveFileCapability(event, request.inputCapability, 'cutter-input');
|
||||||
if (!inputFile || !cutterInputIdentityMatches(inputFile)) return { success: false, outputName: null };
|
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;
|
let outputFile: string | null = null;
|
||||||
const testRoot = process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT;
|
const testRoot = process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT;
|
||||||
if (testRoot && typeof request.outputName === 'string' && path.basename(request.outputName) === request.outputName) {
|
if (testRoot && typeof request.outputName === 'string' && path.basename(request.outputName) === request.outputName) {
|
||||||
const candidate = path.join(testRoot, request.outputName);
|
const candidate = path.join(testRoot, request.outputName);
|
||||||
if (isPathInsideDirectory(testRoot, candidate)) {
|
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]);
|
outputFile = resolveFileCapability(event, outputCapability.token, 'cutter-output', true, [inputFile]);
|
||||||
}
|
}
|
||||||
} else {
|
} 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!, {
|
const result = await dialog.showSaveDialog(mainWindow!, {
|
||||||
defaultPath: defaultName,
|
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 };
|
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]);
|
outputFile = resolveFileCapability(event, outputCapability.token, 'cutter-output', true, [inputFile]);
|
||||||
}
|
}
|
||||||
if (!outputFile) return { success: false, outputName: null };
|
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);
|
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 };
|
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();
|
startDebugLogFlushTimer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { openDatabase } = require('./main/infra/db');
|
|
||||||
const { migrateJsonToSqlite } = require('./main/domain/migrator');
|
|
||||||
const dbPath = path.join(APPDATA_DIR, 'app.db');
|
const dbPath = path.join(APPDATA_DIR, 'app.db');
|
||||||
const database: DbHandle = openDatabase(dbPath);
|
const database: DbHandle = openDatabase(dbPath);
|
||||||
appDb = database;
|
appDb = database;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { calculateCutterExportProgress, createCutterExportPlan } from './cutter-export';
|
import { calculateCutterExportProgress, createCutterExportPlan, parseCutterHardwareEncoders } from './cutter-export';
|
||||||
|
|
||||||
describe('cutter export segments', () => {
|
describe('cutter export segments', () => {
|
||||||
test('sorts playable segments and preserves the caller input', () => {
|
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(45, plan)).toBe(100);
|
||||||
expect(calculateCutterExportProgress(-5, plan)).toBe(0);
|
expect(calculateCutterExportProgress(-5, plan)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('builds a rotated quality MP4 profile for an explicitly selected audio stream', () => {
|
||||||
|
const plan = createCutterExportPlan({
|
||||||
|
inputFile: 'input.mov',
|
||||||
|
outputFile: 'output.mp4',
|
||||||
|
segments: [{ start: 0, end: 20 }],
|
||||||
|
hasAudio: true,
|
||||||
|
profile: 'quality',
|
||||||
|
encoder: 'software',
|
||||||
|
audioStreamIndex: 1,
|
||||||
|
rotation: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan.filterComplex).toContain('[0:v]trim=start=0:end=20,setpts=PTS-STARTPTS,transpose=1[v0]');
|
||||||
|
expect(plan.filterComplex).toContain('[0:a:1]atrim=start=0:end=20,asetpts=PTS-STARTPTS[a0]');
|
||||||
|
expect(plan.ffmpegArgs).toContain('-noautorotate');
|
||||||
|
expect(plan.ffmpegArgs).toEqual(expect.arrayContaining(['-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to a compatible software encoder when the requested hardware encoder is absent', () => {
|
||||||
|
const plan = createCutterExportPlan({
|
||||||
|
inputFile: 'input.mp4',
|
||||||
|
outputFile: 'output.mp4',
|
||||||
|
segments: [{ start: 0, end: 20 }],
|
||||||
|
hasAudio: true,
|
||||||
|
profile: 'fast',
|
||||||
|
encoder: 'h264_nvenc',
|
||||||
|
availableHardwareEncoders: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan.selectedEncoder).toBe('libx264');
|
||||||
|
expect(plan.hardwareFallback).toBe(true);
|
||||||
|
expect(plan.ffmpegArgs).toEqual(expect.arrayContaining(['-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires the lossless archive profile to use its compatible MKV container', () => {
|
||||||
|
expect(() => createCutterExportPlan({
|
||||||
|
inputFile: 'input.mp4',
|
||||||
|
outputFile: 'archive.mp4',
|
||||||
|
segments: [{ start: 0, end: 20 }],
|
||||||
|
hasAudio: true,
|
||||||
|
profile: 'archive',
|
||||||
|
encoder: 'software',
|
||||||
|
})).toThrow('MKV');
|
||||||
|
|
||||||
|
const plan = createCutterExportPlan({
|
||||||
|
inputFile: 'input.mp4',
|
||||||
|
outputFile: 'archive.mkv',
|
||||||
|
segments: [{ start: 0, end: 20 }],
|
||||||
|
hasAudio: true,
|
||||||
|
profile: 'archive',
|
||||||
|
encoder: 'software',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan.ffmpegArgs).toEqual(expect.arrayContaining(['-c:v', 'ffv1', '-c:a', 'flac', '-pix_fmt', 'yuv420p']));
|
||||||
|
expect(plan.ffmpegArgs).not.toContain('+faststart');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recognizes only offered H.264 hardware encoders from an ffmpeg probe', () => {
|
||||||
|
expect(parseCutterHardwareEncoders(' V..... h264_nvenc NVIDIA NVENC H.264 encoder\n V..... h264_qsv H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (Intel Quick Sync Video acceleration)\n V..... hevc_amf AMD AMF HEVC encoder')).toEqual(['h264_nvenc', 'h264_qsv']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
|
import * as path from 'node:path';
|
||||||
import type { EditorSegment } from './video-editor';
|
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 {
|
export interface CutterExportPlanOptions {
|
||||||
inputFile: string;
|
inputFile: string;
|
||||||
outputFile: string;
|
outputFile: string;
|
||||||
segments: readonly EditorSegment[];
|
segments: readonly EditorSegment[];
|
||||||
hasAudio: boolean;
|
hasAudio: boolean;
|
||||||
|
profile?: CutterExportProfile;
|
||||||
|
encoder?: CutterExportEncoder;
|
||||||
|
availableHardwareEncoders?: readonly CutterHardwareEncoder[];
|
||||||
|
audioStreamIndex?: number;
|
||||||
|
rotation?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CutterExportPlan {
|
export interface CutterExportPlan {
|
||||||
@@ -12,9 +35,13 @@ export interface CutterExportPlan {
|
|||||||
remainingDuration: number;
|
remainingDuration: number;
|
||||||
filterComplex: string;
|
filterComplex: string;
|
||||||
ffmpegArgs: string[];
|
ffmpegArgs: string[];
|
||||||
|
profile: CutterExportProfile;
|
||||||
|
selectedEncoder: 'libx264' | 'ffv1' | CutterHardwareEncoder;
|
||||||
|
hardwareFallback: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const precision = 9;
|
const precision = 9;
|
||||||
|
const hardwareEncoders: CutterHardwareEncoder[] = ['h264_nvenc', 'h264_qsv', 'h264_amf'];
|
||||||
|
|
||||||
function round(value: number): number {
|
function round(value: number): number {
|
||||||
return Number(value.toFixed(precision));
|
return Number(value.toFixed(precision));
|
||||||
@@ -53,17 +80,46 @@ function normalizeSegments(segments: readonly EditorSegment[]): EditorSegment[]
|
|||||||
return normalized;
|
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 filters: string[] = [];
|
||||||
const concatInputs: string[] = [];
|
const concatInputs: string[] = [];
|
||||||
|
const rotationFilter = videoRotationFilter(rotation);
|
||||||
|
const audioInput = audioStreamIndex === 0 ? '[0:a]' : `[0:a:${audioStreamIndex}]`;
|
||||||
|
|
||||||
segments.forEach((segment, index) => {
|
segments.forEach((segment, index) => {
|
||||||
const start = formatSeconds(segment.start);
|
const start = formatSeconds(segment.start);
|
||||||
const end = formatSeconds(segment.end);
|
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}]`);
|
concatInputs.push(`[v${index}]`);
|
||||||
if (hasAudio) {
|
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}]`);
|
concatInputs.push(`[a${index}]`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -72,31 +128,89 @@ function createFilterComplex(segments: readonly EditorSegment[], hasAudio: boole
|
|||||||
return filters.join(';');
|
return filters.join(';');
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFfmpegArgs(inputFile: string, outputFile: string, filterComplex: string, hasAudio: boolean): string[] {
|
function resolveEncoder(profile: CutterExportProfile, requested: CutterExportEncoder, availableHardwareEncoders: readonly CutterHardwareEncoder[]): { selectedEncoder: CutterExportPlan['selectedEncoder']; hardwareFallback: boolean } {
|
||||||
const args = [
|
if (profile === 'archive') {
|
||||||
'-i', inputFile,
|
return { selectedEncoder: 'ffv1', hardwareFallback: requested !== 'software' };
|
||||||
'-filter_complex', filterComplex,
|
}
|
||||||
'-map', '[outv]',
|
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]');
|
if (hasAudio) args.push('-map', '[outa]');
|
||||||
args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p');
|
if (selectedEncoder === 'ffv1') args.push('-c:v', 'ffv1', '-level', '3', '-g', '1');
|
||||||
if (hasAudio) args.push('-c:a', 'aac', '-b:a', '160k');
|
else if (selectedEncoder === 'libx264') args.push(...softwareVideoArgs(profile));
|
||||||
else args.push('-an');
|
else args.push(...hardwareVideoArgs(profile as Exclude<CutterExportProfile, 'archive'>, selectedEncoder));
|
||||||
args.push('-movflags', '+faststart', '-progress', 'pipe:1', '-y', outputFile);
|
args.push('-pix_fmt', 'yuv420p', ...audioArgs(profile, hasAudio));
|
||||||
|
if (profile !== 'archive') args.push('-movflags', '+faststart');
|
||||||
|
args.push('-progress', 'pipe:1', '-y', outputFile);
|
||||||
return args;
|
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 {
|
export function createCutterExportPlan(options: CutterExportPlanOptions): CutterExportPlan {
|
||||||
const inputFile = validatePath(options.inputFile, 'inputFile');
|
const inputFile = validatePath(options.inputFile, 'inputFile');
|
||||||
const outputFile = validatePath(options.outputFile, 'outputFile');
|
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 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 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 {
|
return {
|
||||||
segments,
|
segments,
|
||||||
remainingDuration,
|
remainingDuration,
|
||||||
filterComplex,
|
filterComplex,
|
||||||
ffmpegArgs: createFfmpegArgs(inputFile, outputFile, filterComplex, options.hasAudio),
|
ffmpegArgs: createFfmpegArgs(inputFile, outputFile, filterComplex, options.hasAudio, profile, encoder.selectedEncoder, rotation),
|
||||||
|
profile,
|
||||||
|
selectedEncoder: encoder.selectedEncoder,
|
||||||
|
hardwareFallback: encoder.hardwareFallback,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { createCutterProjectAutosaveStore } from './cutter-project';
|
||||||
|
|
||||||
|
let directory: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'twitch-vod-manager-cutter-project-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cutter project autosave', () => {
|
||||||
|
test('restores a crash-saved edit after recreating the local store', () => {
|
||||||
|
const autosavePath = path.join(directory, 'cutter-projects.json');
|
||||||
|
const source = { path: 'C:\\Media\\source.mp4', size: 2_048_000, mtimeMs: 1_725_000_000_000 };
|
||||||
|
const savingStore = createCutterProjectAutosaveStore(autosavePath);
|
||||||
|
|
||||||
|
savingStore.save({
|
||||||
|
source,
|
||||||
|
duration: 120,
|
||||||
|
fps: 30,
|
||||||
|
trimStart: 5,
|
||||||
|
trimEnd: 110,
|
||||||
|
cuts: [{ id: 'cut-1', start: 32, end: 46 }],
|
||||||
|
profile: 'quality',
|
||||||
|
encoder: 'software',
|
||||||
|
audioStreamIndex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recovered = createCutterProjectAutosaveStore(autosavePath).find(source);
|
||||||
|
|
||||||
|
expect(recovered).toMatchObject({
|
||||||
|
source,
|
||||||
|
trimStart: 5,
|
||||||
|
trimEnd: 110,
|
||||||
|
cuts: [{ id: 'cut-1', start: 32, end: 46 }],
|
||||||
|
profile: 'quality',
|
||||||
|
audioStreamIndex: 1,
|
||||||
|
});
|
||||||
|
expect(fs.existsSync(`${autosavePath}.tmp`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['path', (source: { path: string; size: number; mtimeMs: number }) => ({ ...source, path: 'C:\\Media\\renamed.mp4' })],
|
||||||
|
['size', (source: { path: string; size: number; mtimeMs: number }) => ({ ...source, size: source.size + 1 })],
|
||||||
|
['modification time', (source: { path: string; size: number; mtimeMs: number }) => ({ ...source, mtimeMs: source.mtimeMs + 1 })],
|
||||||
|
])('rejects a recovery when the source %s changed', (_field, mutate) => {
|
||||||
|
const autosavePath = path.join(directory, 'cutter-projects.json');
|
||||||
|
const savedSource = { path: 'C:\\Media\\source.mp4', size: 2_048_000, mtimeMs: 1_725_000_000_000 };
|
||||||
|
const changedSource = mutate(savedSource);
|
||||||
|
const store = createCutterProjectAutosaveStore(autosavePath);
|
||||||
|
store.save({
|
||||||
|
source: savedSource,
|
||||||
|
duration: 120,
|
||||||
|
fps: 30,
|
||||||
|
trimStart: 0,
|
||||||
|
trimEnd: 120,
|
||||||
|
cuts: [],
|
||||||
|
profile: 'balanced',
|
||||||
|
encoder: 'software',
|
||||||
|
audioStreamIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.find(changedSource)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discards the current source recovery', () => {
|
||||||
|
const autosavePath = path.join(directory, 'cutter-projects.json');
|
||||||
|
const savedSource = { path: 'C:\\Media\\source.mp4', size: 2_048_000, mtimeMs: 1_725_000_000_000 };
|
||||||
|
const store = createCutterProjectAutosaveStore(autosavePath);
|
||||||
|
store.save({
|
||||||
|
source: savedSource,
|
||||||
|
duration: 120,
|
||||||
|
fps: 30,
|
||||||
|
trimStart: 0,
|
||||||
|
trimEnd: 120,
|
||||||
|
cuts: [],
|
||||||
|
profile: 'balanced',
|
||||||
|
encoder: 'software',
|
||||||
|
audioStreamIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.discard(savedSource)).toBe(true);
|
||||||
|
expect(createCutterProjectAutosaveStore(autosavePath).find(savedSource)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { writeFileAtomicSync } from '../infra/fs-atomic';
|
||||||
|
import type { CutterExportEncoder, CutterExportProfile } from './cutter-export';
|
||||||
|
|
||||||
|
export interface CutterProjectSource {
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CutterProject {
|
||||||
|
source: CutterProjectSource;
|
||||||
|
duration: number;
|
||||||
|
fps: number;
|
||||||
|
trimStart: number;
|
||||||
|
trimEnd: number;
|
||||||
|
cuts: Array<{ id: string; start: number; end: number }>;
|
||||||
|
profile: CutterExportProfile;
|
||||||
|
encoder: CutterExportEncoder;
|
||||||
|
audioStreamIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CutterProjectDocument {
|
||||||
|
version: 1;
|
||||||
|
projects: CutterProject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneProject(project: CutterProject): CutterProject {
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
source: { ...project.source },
|
||||||
|
cuts: project.cuts.map((cut) => ({ ...cut })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceKey(source: CutterProjectSource): string {
|
||||||
|
return `${source.path}\u0000${source.size}\u0000${source.mtimeMs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProjectSource(value: unknown): value is CutterProjectSource {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const source = value as Record<string, unknown>;
|
||||||
|
return typeof source.path === 'string'
|
||||||
|
&& source.path.length > 0
|
||||||
|
&& typeof source.size === 'number'
|
||||||
|
&& Number.isFinite(source.size)
|
||||||
|
&& source.size >= 0
|
||||||
|
&& typeof source.mtimeMs === 'number'
|
||||||
|
&& Number.isFinite(source.mtimeMs)
|
||||||
|
&& source.mtimeMs >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProject(value: unknown): value is CutterProject {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const project = value as Record<string, unknown>;
|
||||||
|
return isProjectSource(project.source)
|
||||||
|
&& typeof project.duration === 'number'
|
||||||
|
&& Number.isFinite(project.duration)
|
||||||
|
&& project.duration > 0
|
||||||
|
&& typeof project.fps === 'number'
|
||||||
|
&& Number.isFinite(project.fps)
|
||||||
|
&& project.fps > 0
|
||||||
|
&& typeof project.trimStart === 'number'
|
||||||
|
&& Number.isFinite(project.trimStart)
|
||||||
|
&& typeof project.trimEnd === 'number'
|
||||||
|
&& Number.isFinite(project.trimEnd)
|
||||||
|
&& Array.isArray(project.cuts)
|
||||||
|
&& project.cuts.every((cut) => cut && typeof cut === 'object'
|
||||||
|
&& typeof (cut as Record<string, unknown>).id === 'string'
|
||||||
|
&& typeof (cut as Record<string, unknown>).start === 'number'
|
||||||
|
&& Number.isFinite((cut as Record<string, unknown>).start)
|
||||||
|
&& typeof (cut as Record<string, unknown>).end === 'number'
|
||||||
|
&& Number.isFinite((cut as Record<string, unknown>).end))
|
||||||
|
&& (project.profile === 'quality' || project.profile === 'balanced' || project.profile === 'fast' || project.profile === 'archive')
|
||||||
|
&& (project.encoder === 'software' || project.encoder === 'h264_nvenc' || project.encoder === 'h264_qsv' || project.encoder === 'h264_amf')
|
||||||
|
&& typeof project.audioStreamIndex === 'number'
|
||||||
|
&& Number.isInteger(project.audioStreamIndex)
|
||||||
|
&& project.audioStreamIndex >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDocument(filePath: string): CutterProjectDocument {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>;
|
||||||
|
if (parsed.version !== 1 || !Array.isArray(parsed.projects)) return { version: 1, projects: [] };
|
||||||
|
return { version: 1, projects: parsed.projects.filter(isProject).map(cloneProject) };
|
||||||
|
} catch {
|
||||||
|
return { version: 1, projects: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CutterProjectAutosaveStore {
|
||||||
|
find(source: CutterProjectSource): CutterProject | null;
|
||||||
|
save(project: CutterProject): void;
|
||||||
|
discard(source: CutterProjectSource): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCutterProjectAutosaveStore(filePath: string): CutterProjectAutosaveStore {
|
||||||
|
const write = (document: CutterProjectDocument): void => {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
writeFileAtomicSync(filePath, JSON.stringify(document));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
find(source) {
|
||||||
|
const project = readDocument(filePath).projects.find((entry) => sourceKey(entry.source) === sourceKey(source));
|
||||||
|
return project ? cloneProject(project) : null;
|
||||||
|
},
|
||||||
|
save(project) {
|
||||||
|
if (!isProject(project)) throw new Error('Invalid cutter project');
|
||||||
|
const document = readDocument(filePath);
|
||||||
|
const key = sourceKey(project.source);
|
||||||
|
const projects = document.projects.filter((entry) => sourceKey(entry.source) !== key);
|
||||||
|
projects.push(cloneProject(project));
|
||||||
|
write({ version: 1, projects });
|
||||||
|
},
|
||||||
|
discard(source) {
|
||||||
|
const document = readDocument(filePath);
|
||||||
|
const key = sourceKey(source);
|
||||||
|
const projects = document.projects.filter((entry) => sourceKey(entry.source) !== key);
|
||||||
|
if (projects.length === document.projects.length) return false;
|
||||||
|
write({ version: 1, projects });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -193,6 +193,11 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
prepareVideoEditorWaveform: (capability: string, jobId: number): Promise<VideoEditorWaveform | null> => ipcRenderer.invoke('prepare-video-editor-waveform', capability, jobId),
|
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),
|
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),
|
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),
|
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'),
|
cancelVideoEdit: (): Promise<boolean> => ipcRenderer.invoke('cancel-video-edit'),
|
||||||
cutVideo: (inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }> =>
|
cutVideo: (inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }> =>
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ let cutterScrubSeekInFlight = false;
|
|||||||
let cutterScrubResumePlayback = false;
|
let cutterScrubResumePlayback = false;
|
||||||
let cutterScrubGeneration = 0;
|
let cutterScrubGeneration = 0;
|
||||||
let cutterDiscardResolver: ((discard: boolean) => void) | null = null;
|
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 cutterMaximumCuts = 64;
|
||||||
const cutterFrameTolerance = 1e-8;
|
const cutterFrameTolerance = 1e-8;
|
||||||
|
|
||||||
@@ -139,12 +145,194 @@ function getCutterPlayableDuration(): number {
|
|||||||
return Math.max(0, cutterEditorState.trimEnd - cutterEditorState.trimStart - removed);
|
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 {
|
function commitCutterChange(before: CutterEditorState): void {
|
||||||
if (!cutterEditorState || cutterStatesEqual(before, cutterEditorState)) return;
|
if (!cutterEditorState || cutterStatesEqual(before, cutterEditorState)) return;
|
||||||
cutterHistoryPast.push(cloneCutterState(before));
|
cutterHistoryPast.push(cloneCutterState(before));
|
||||||
if (cutterHistoryPast.length > 100) cutterHistoryPast.shift();
|
if (cutterHistoryPast.length > 100) cutterHistoryPast.shift();
|
||||||
cutterHistoryFuture = [];
|
cutterHistoryFuture = [];
|
||||||
updateCutterHistoryButtons();
|
updateCutterHistoryButtons();
|
||||||
|
scheduleCutterAutosave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCutterHistoryButtons(): void {
|
function updateCutterHistoryButtons(): void {
|
||||||
@@ -718,6 +906,11 @@ function setCutterControlsEnabled(enabled: boolean): void {
|
|||||||
byId<HTMLButtonElement>('cutterZoomInBtn').disabled = !enabled;
|
byId<HTMLButtonElement>('cutterZoomInBtn').disabled = !enabled;
|
||||||
byId<HTMLButtonElement>('cutterZoomOutBtn').disabled = !enabled;
|
byId<HTMLButtonElement>('cutterZoomOutBtn').disabled = !enabled;
|
||||||
byId<HTMLButtonElement>('cutterNewCutBtn').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');
|
const volumeControl = document.querySelector<HTMLElement>('.cutter-volume-control');
|
||||||
volumeControl?.classList.toggle('disabled', !enabled);
|
volumeControl?.classList.toggle('disabled', !enabled);
|
||||||
volumeControl?.setAttribute('aria-disabled', String(!enabled));
|
volumeControl?.setAttribute('aria-disabled', String(!enabled));
|
||||||
@@ -872,6 +1065,11 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise<void>
|
|||||||
cutterHistoryPast = [];
|
cutterHistoryPast = [];
|
||||||
cutterHistoryFuture = [];
|
cutterHistoryFuture = [];
|
||||||
cutterActiveCutId = null;
|
cutterActiveCutId = null;
|
||||||
|
cutterExportProfile = 'balanced';
|
||||||
|
cutterExportEncoder = 'software';
|
||||||
|
cutterAudioStreamIndex = media.info.audioStreams[0]?.index ?? 0;
|
||||||
|
renderCutterProjectRecovery(null);
|
||||||
|
updateCutterAudioStreams();
|
||||||
cutterZoom = getInitialCutterZoom(media.info.duration);
|
cutterZoom = getInitialCutterZoom(media.info.duration);
|
||||||
byId<HTMLInputElement>('cutterZoom').value = String(cutterZoom);
|
byId<HTMLInputElement>('cutterZoom').value = String(cutterZoom);
|
||||||
byId<HTMLInputElement>('cutterFilePath').value = file.name;
|
byId<HTMLInputElement>('cutterFilePath').value = file.name;
|
||||||
@@ -899,6 +1097,13 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise<void>
|
|||||||
updateCutterZoom(cutterZoom);
|
updateCutterZoom(cutterZoom);
|
||||||
renderCutterEditor();
|
renderCutterEditor();
|
||||||
updateCutterPlayhead(0);
|
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 requestCutterWaveform(file, media.jobId, generation);
|
||||||
void requestCutterAssets();
|
void requestCutterAssets();
|
||||||
}
|
}
|
||||||
@@ -940,6 +1145,10 @@ function confirmCutterReplacement(file: FileCapabilityReference): Promise<boolea
|
|||||||
async function requestCutterVideoReplacement(file: FileCapabilityReference): Promise<void> {
|
async function requestCutterVideoReplacement(file: FileCapabilityReference): Promise<void> {
|
||||||
if (!file || isCutting) return;
|
if (!file || isCutting) return;
|
||||||
if (!await confirmCutterReplacement(file)) return;
|
if (!await confirmCutterReplacement(file)) return;
|
||||||
|
if (cutterEditorState && !await persistCutterProject(false)) {
|
||||||
|
showAppToast('Projekt konnte nicht gespeichert werden', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await loadCutterFromPath(file);
|
await loadCutterFromPath(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,6 +1232,7 @@ function undoCutterEdit(): void {
|
|||||||
cutterActiveCutId = null;
|
cutterActiveCutId = null;
|
||||||
seekCutterVideo(cutterEditorState.trimStart);
|
seekCutterVideo(cutterEditorState.trimStart);
|
||||||
renderCutterEditor();
|
renderCutterEditor();
|
||||||
|
scheduleCutterAutosave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function redoCutterEdit(): void {
|
function redoCutterEdit(): void {
|
||||||
@@ -1034,6 +1244,7 @@ function redoCutterEdit(): void {
|
|||||||
cutterActiveCutId = null;
|
cutterActiveCutId = null;
|
||||||
seekCutterVideo(cutterEditorState.trimStart);
|
seekCutterVideo(cutterEditorState.trimStart);
|
||||||
renderCutterEditor();
|
renderCutterEditor();
|
||||||
|
scheduleCutterAutosave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCutterPreviewMode(enabled: boolean): void {
|
function setCutterPreviewMode(enabled: boolean): void {
|
||||||
@@ -1397,6 +1608,9 @@ async function startCutting(): Promise<void> {
|
|||||||
trimStart: cutterEditorState.trimStart,
|
trimStart: cutterEditorState.trimStart,
|
||||||
trimEnd: cutterEditorState.trimEnd,
|
trimEnd: cutterEditorState.trimEnd,
|
||||||
cuts: cutterEditorState.cuts.map((cut) => ({ ...cut })),
|
cuts: cutterEditorState.cuts.map((cut) => ({ ...cut })),
|
||||||
|
profile: cutterExportProfile,
|
||||||
|
encoder: cutterExportEncoder,
|
||||||
|
audioStreamIndex: cutterAudioStreamIndex,
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showAppToast(UI_TEXT.cutter.exportSuccess, 'info');
|
showAppToast(UI_TEXT.cutter.exportSuccess, 'info');
|
||||||
|
|||||||
Vendored
+27
@@ -168,6 +168,8 @@ interface VideoInfo {
|
|||||||
audioCodec: string | null;
|
audioCodec: string | null;
|
||||||
previewCompatible: boolean;
|
previewCompatible: boolean;
|
||||||
variableFrameRate: boolean;
|
variableFrameRate: boolean;
|
||||||
|
rotation: 0 | 90 | 180 | 270;
|
||||||
|
audioStreams: Array<{ index: number; codec: string; channels: number; language: string | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SecretStatus {
|
interface SecretStatus {
|
||||||
@@ -212,6 +214,26 @@ interface VideoEditExportRequest {
|
|||||||
trimStart: number;
|
trimStart: number;
|
||||||
trimEnd: number;
|
trimEnd: number;
|
||||||
cuts: Array<{ id: string; start: number; end: 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 {
|
interface FileCapabilityReference {
|
||||||
@@ -451,6 +473,11 @@ interface ApiBridge {
|
|||||||
prepareVideoEditorWaveform(capability: string, jobId: number): Promise<VideoEditorWaveform | null>;
|
prepareVideoEditorWaveform(capability: string, jobId: number): Promise<VideoEditorWaveform | null>;
|
||||||
prepareVideoEditorAssets(capability: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null>;
|
prepareVideoEditorAssets(capability: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null>;
|
||||||
cancelVideoEditorAssets(jobId: number): Promise<boolean>;
|
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 }>;
|
exportVideoEdit(request: VideoEditExportRequest): Promise<{ success: boolean; outputCapability?: string; outputName: string | null; cancelled?: boolean }>;
|
||||||
cancelVideoEdit(): Promise<boolean>;
|
cancelVideoEdit(): Promise<boolean>;
|
||||||
cutVideo(inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }>;
|
cutVideo(inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }>;
|
||||||
|
|||||||
@@ -3174,6 +3174,23 @@ input[type="checkbox"].vod-select-checkbox {
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.cutter-workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px minmax(0, 1fr);
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
@@ -3354,6 +3371,25 @@ input[type="checkbox"].vod-select-checkbox {
|
|||||||
transform: translateX(16px);
|
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-trim-card,
|
||||||
.cutter-cut-section {
|
.cutter-cut-section {
|
||||||
padding: 11px;
|
padding: 11px;
|
||||||
|
|||||||
Reference in New Issue
Block a user